diff --git a/BUDGET_SYSTEM.md b/BUDGET_SYSTEM.md
deleted file mode 100644
index 2adcb0fd..00000000
--- a/BUDGET_SYSTEM.md
+++ /dev/null
@@ -1,246 +0,0 @@
-# Система бюджетов отделов в модуле закупок
-
-## Где выставляется бюджет?
-
-### 1️⃣ **Django Admin (Основное место)**
-
-```
-http://localhost:9000/admin/procurement/budget/
-```
-
-**Процесс:**
-1. Перейти в Django Admin → Procurement → Budget
-2. Нажать "Add budget"
-3. Заполнить форму:
- - **Department** — выбрать отдел (IT, HR, финансы и т.д.)
- - **Year** — год (2025, 2026)
- - **Quarter** — квартал (1, 2, 3, 4)
- - **Allocated amount** — сумма выделенного бюджета в рублях
- - **Spent amount** — (заполняется автоматически, если оставить 0)
-
-### 2️⃣ **REST API (для приложений)**
-
-**Создание бюджета:**
-```bash
-POST /api/procurement/budgets/
-Content-Type: application/json
-
-{
- "department": 5, # ID отдела
- "year": 2025,
- "quarter": 4,
- "allocated_amount": "500000.00"
-}
-```
-
-**Редактирование:**
-```bash
-PATCH /api/procurement/budgets/1/
-{
- "allocated_amount": "600000.00"
-}
-```
-
-**Получение текущего квартала:**
-```bash
-GET /api/procurement/budgets/current_quarter/
-```
-
-**Получить бюджет своего отдела:**
-```bash
-GET /api/procurement/budgets/my-department/
-```
-
-## Структура модели Budget
-
-```python
-class Budget(models.Model):
- department # ForeignKey на Department
- year # 2025, 2026, ...
- quarter # 1, 2, 3, 4
- allocated_amount # Выделено денег (основное число!)
- spent_amount # Потрачено (меняется при одобрении заявок)
- created_at # Когда создан
- updated_at # Когда обновлён
-```
-
-**Ключевое свойство:** `(department, year, quarter)` — уникальный набор!
-```
-Нельзя создать два бюджета для одного отдела на один период!
-```
-
-## Примеры бюджетов
-
-### Компания с 3 отделами на Q4 2025:
-
-```
-┌────────────────┬──────┬─────────┬──────────────┐
-│ Отдел │ 2025 │ Q4 │ Бюджет │
-├────────────────┼──────┼─────────┼──────────────┤
-│ IT │ 2025 │ 4 │ 1,000,000₽ │
-│ HR │ 2025 │ 4 │ 300,000₽ │
-│ Финансы │ 2025 │ 4 │ 500,000₽ │
-│ Маркетинг │ 2025 │ 4 │ 250,000₽ │
-└────────────────┴──────┴─────────┴──────────────┘
-```
-
-## Как работает проверка бюджета?
-
-Когда пользователь отправляет заявку на согласование:
-
-```python
-# 1. Система получает текущий квартал
-now = timezone.now()
-quarter = (now.month - 1) // 3 + 1
-
-# 2. Ищет бюджет отдела на этот период
-budget = Budget.objects.get(
- department=procurement_request.department,
- year=now.year,
- quarter=quarter
-)
-
-# 3. Проверяет остаток
-remaining = budget.remaining_amount # allocated - spent
-if remaining >= procurement_request.estimated_cost:
- # ✅ Можно отправить на согласование
-else:
- # ❌ Недостаточно бюджета
- return error
-```
-
-## Ключевые свойства Budget
-
-```python
-# Остаток бюджета
-remaining_amount = allocated_amount - spent_amount
-
-# Зарезервировано (pending заявки)
-reserved_amount = sum(pending_requests.estimated_cost)
-
-# Доступно с учётом резерва
-available_amount = remaining_amount - reserved_amount
-
-# Процент использования
-utilization_percentage = (spent_amount / allocated_amount) * 100
-
-# Можно ли потратить X?
-can_spend(amount: Decimal) → bool
-```
-
-## Пример в коде
-
-```python
-# Проверка в views.py при отправке заявки
-available, remaining = procurement_request.check_budget_available()
-
-if not available:
- return Response({
- 'error': 'Недостаточно бюджета',
- 'required': float(procurement_request.estimated_cost),
- 'remaining': float(remaining),
- }, status=400)
-```
-
-## Алерты при низком бюджете
-
-Система автоматически отправляет уведомления руководителю отдела:
-
-```python
-# budget_low (остаток < 20%)
-"Бюджет отдела снижается. Остаток: 45,000₽ (18%)"
-
-# budget_critical (остаток < 10%)
-"⚠️ Критически низкий бюджет! Остаток: 8,000₽ (3%)"
-```
-
-**Когда:** При одобрении заявки если бюджет падает ниже порогов
-
-## Как менять бюджет?
-
-### ✅ Правильно (сумма выделенного):
-```
-Отдел IT на Q4 2025: 1,000,000₽
-Потом изменили: 1,500,000₽ (увеличили бюджет)
-```
-
-### ❌ Неправильно (not spent_amount):
-```
-❌ Не меняйте spent_amount вручную - это меняется автоматически
-```
-
-## Права доступа
-
-| Action | Кто может |
-|--------|-----------|
-| Создать бюджет | `CanManageBudget` (админ) |
-| Редактировать | `CanManageBudget` (админ) |
-| Удалить | `CanManageBudget` (админ) |
-| Просмотреть свой | Руководитель отдела |
-| Просмотреть все | Суперпользователь/Админ |
-
-## Типичный цикл
-
-```
-Q1 2025: Выделили IT-отделу 1,000,000₽
- ↓
-Заявка #1: 50,000₽ → одобрена
-Заявка #2: 100,000₽ → одобрена
-Zaявка #3: 30,000₽ → одобрена
- ↓
-Потрачено: 180,000₽
-Остаток: 820,000₽
-Использовано: 18%
- ↓
-Q2 2025: Новый квартал, новый бюджет
-```
-
-## SQL для статистики
-
-```sql
--- Сколько потрачено каждый отдел на Q4 2025?
-SELECT
- d.name,
- b.allocated_amount,
- b.spent_amount,
- (b.allocated_amount - b.spent_amount) as remaining
-FROM procurement_budget b
-JOIN employees_department d ON b.department_id = d.id
-WHERE b.year = 2025 AND b.quarter = 4;
-
--- Самый загруженный отдел по бюджету
-SELECT
- d.name,
- b.allocated_amount,
- (b.spent_amount::float / b.allocated_amount * 100) as utilization
-FROM procurement_budget b
-JOIN employees_department d ON b.department_id = d.id
-WHERE b.year = 2025
-ORDER BY utilization DESC;
-```
-
-## Интеграция с заявками
-
-```
-ProcurementRequest (заявка)
- ├─ department_id
- ├─ estimated_cost
- └─ check_budget_available()
- ↓ ищет
- Budget (бюджет)
- ├─ department
- ├─ allocated_amount
- └─ spent_amount
-```
-
-## Автоматические обновления spent_amount
-
-```python
-# На данный момент spent_amount меняется вручную в:
-# - Django Admin
-# - API PATCH запрос
-
-# В будущем можно автоматизировать при:
-# - Завершении заявки (status=COMPLETED)
-# - Поступлении счёта (через другую модель Invoice)
-```
diff --git a/CHAT_PAGE_RENDERING_ARCHITECTURE.md b/CHAT_PAGE_RENDERING_ARCHITECTURE.md
deleted file mode 100644
index 0e3357b9..00000000
--- a/CHAT_PAGE_RENDERING_ARCHITECTURE.md
+++ /dev/null
@@ -1,827 +0,0 @@
-# Архитектура рендеринга и наполнения страницы чата
-
-## 📋 Оглавление
-1. [Общая схема](#общая-схема)
-2. [Серверная часть (Backend)](#серверная-часть-backend)
-3. [Шаблон страницы (Template)](#шаблон-страницы-template)
-4. [JavaScript инициализация](#javascript-инициализация)
-5. [WebSocket соединение](#websocket-соединение)
-6. [Рендеринг сообщений](#рендеринг-сообщений)
-7. [Поток данных](#поток-данных)
-
----
-
-## 🎯 Общая схема
-
-Страница чата работает по следующей архитектуре:
-
-```
-HTTP Request → Django View → HTML Template → DOM Ready → WebSocket Connect → Load Messages
- ↓ ↓ ↓ ↓ ↓
- Context Data Initial HTML Init JS Real-time Render to DOM
-```
-
----
-
-## 🔧 Серверная часть (Backend)
-
-### 1. `ChatDetailView` (communications/views.py)
-
-**Класс:** `ChatDetailView(LoginRequiredMixin, DetailView, FormView)`
-
-**Ответственность:**
-- Проверка прав доступа пользователя к чату
-- Подготовка контекста для шаблона
-- Установка заголовков для отключения кэширования
-
-#### Ключевые методы:
-
-```python
-def get_object(self):
- """Получает объект чата с проверкой прав доступа"""
- # Фильтрует по типу чата: global/department/private/group
- # Проверяет membership для приватных чатов
-```
-
-```python
-def get_context_data(self, **kwargs):
- """Подготавливает контекст для шаблона"""
- # 1. Проверка доступа через _user_has_access()
- # 2. Загрузка сообщений (последние 50, в обратном порядке)
- # 3. Определение has_more (есть ли еще история)
- # 4. Вычисление last_read_at и first_unread_id
- # 5. Проверка прав на отправку сообщений
-```
-
-**Контекст, передаваемый в шаблон:**
-```python
-{
- 'chat': Chat, # Объект чата
- 'messages': [...], # Последние 50 сообщений (УСТАРЕЛО - не рендерятся)
- 'messages_has_more': bool, # Есть ли еще история
- 'messages_oldest_id': int, # ID самого старого сообщения
- 'messages_oldest_ts': int, # Timestamp самого старого сообщения
- 'messages_page_size': 50, # Размер страницы
- 'participants': QuerySet, # Участники чата
- 'last_read_at': datetime, # Время последнего прочтения
- 'first_unread_id': int, # ID первого непрочитанного
- 'can_send_messages': bool, # Может ли отправлять сообщения
- 'is_announcement_creator': bool # Создатель объявления
-}
-```
-
-#### Отключение кэширования:
-```python
-def dispatch(self, request, *args, **kwargs):
- response = super().dispatch(request, *args, **kwargs)
- response['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
- response['Pragma'] = 'no-cache'
- response['Expires'] = '0'
- return response
-```
-
----
-
-## 🖼️ Шаблон страницы (Template)
-
-### `templates/communications/chat_detail.html`
-
-#### Структура DOM:
-
-```html
-
-```
-
-**Важно:** Сообщения из контекста `messages` больше НЕ рендерятся в шаблоне!
-Вместо этого используется **WebSocket для загрузки initial_messages**.
-
----
-
-## ⚙️ JavaScript инициализация
-
-### `static/js/pages/chatDetail.js`
-
-**Главный файл инициализации страницы чата.**
-
-#### Этапы инициализации:
-
-```javascript
-// 1. Загрузка модулей
-const modules = await Promise.all([
- import('../components/chatMarkRead.js'),
- import('../components/chatComposer.js'),
- import('../components/chatHistoryLoader.js'),
- import('../components/chatFormManager.js'),
- import('../components/messageRenderer.js'),
- import('../components/initialMessagesLoader.js') // Не используется
-]);
-
-// 2. Ожидание DOM Ready
-document.addEventListener('DOMContentLoaded', initWhenReady);
-
-// 3. Чтение конфигурации из data-атрибутов
-const config = {
- chatId: Number(appContainer.dataset.chatId),
- userId: Number(appContainer.dataset.userId),
- uploadUrl: appContainer.dataset.uploadUrl,
- messagesUrl: appContainer.dataset.messagesUrl,
- // ... и другие
-};
-
-// 4. Ожидание готовности WebSocket
-waitForWebSocket().then((userWs) => {
- initializeComponents(config, userWs);
-});
-```
-
-#### Инициализация компонентов:
-
-```javascript
-function initializeComponents(config, userWs) {
- // 1. FormManager - управление формой
- const formManager = initChatFormManager({ ... });
-
- // 2. MarkRead - отметка прочитанных
- const markReadApi = initChatMarkRead({ ... });
-
- // 3. Composer - композер сообщений
- const composer = initChatComposer({ ... });
-
- // 4. HistoryLoader - загрузка старых сообщений
- const historyLoader = initChatHistoryLoader({ ... });
-
- // 5. MessageRenderer - рендеринг сообщений
- const messageRenderer = new MessageRenderer({ ... });
-
- // 6. Конфигурация WebSocket
- userWs.configure({
- scrollContainerId: 'chatScroll',
- messageRenderer: messageRenderer,
- markReadApi: markReadApi,
- // ...
- });
-
- // 7. Открытие чата в WebSocket
- userWs.openChat(config.chatId, true); // true = загрузить историю
-}
-```
-
----
-
-## 🌐 WebSocket соединение
-
-### `static/js/components/userWebSocket.js`
-
-**Единое WebSocket соединение для всех real-time обновлений.**
-
-#### Инициализация в base.html:
-
-```javascript
-// templates/base.html
-const userWs = initUserWebSocket({
- userId: {{ user.id }},
- badgeManager: badgeManager,
- onListUpdate: (data) => { /* ... */ }
-});
-
-window.userWebSocket = userWs;
-
-// Уведомляем модули о готовности
-window.dispatchEvent(new CustomEvent('user:ws-ready', {
- detail: { api: userWs }
-}));
-```
-
-#### Подключение:
-
-```javascript
-const proto = location.protocol === 'https:' ? 'wss' : 'ws';
-const wsUrl = `${proto}://${location.host}/ws/`;
-const ws = new WebSocket(wsUrl);
-```
-
-#### Открытие чата:
-
-```javascript
-function openChat(chatId, loadHistory = false) {
- send({
- action: 'open_chat',
- chat_id: chatId,
- load_history: loadHistory
- });
-}
-```
-
----
-
-## 📨 Загрузка сообщений через WebSocket
-
-### Backend: `communications/consumers.py`
-
-#### `ChatConsumer.connect()`:
-
-```python
-async def connect(self):
- # 1. Проверка аутентификации
- user = self.scope.get("user")
-
- # 2. Проверка прав доступа к чату
- chat = await self._get_chat(self.chat_id)
- if not await self._user_can_access(chat, user):
- await self.close(code=4403)
- return
-
- # 3. Подключение к группе
- await self.channel_layer.group_add(self.group_name, self.channel_name)
- await self.accept()
-
- # 4. Отправка начальной истории
- await self._send_initial_messages()
-
- # 5. Отметка как прочитанное
- await self._mark_read(self.chat, user)
-
- # 6. Запуск ping цикла
- self.ping_task = asyncio.create_task(self._ping_loop())
-```
-
-#### `_send_initial_messages()`:
-
-```python
-async def _send_initial_messages(self):
- """Отправить начальную историю сообщений при подключении"""
- messages = await self._get_initial_messages(self.chat_id, limit=50)
-
- await self.send_json({
- "type": "initial_messages",
- "messages": messages
- })
-
-@database_sync_to_async
-def _get_initial_messages(self, chat_id: int, limit: int = 50):
- """Получить последние N сообщений из чата"""
- messages = Message.objects.filter(
- chat_id=chat_id
- ).select_related(
- 'author'
- ).prefetch_related(
- 'attachments',
- 'reactions__user'
- ).order_by('-created_at')[:limit]
-
- # Возвращаем в прямом порядке (старые -> новые)
- return [serialize_message(msg) for msg in reversed(list(messages))]
-```
-
-#### Формат сообщения:
-
-```python
-def serialize_message(m: Message) -> dict:
- return {
- "id": m.id,
- "chat_id": m.chat_id,
- "author_id": m.author_id,
- "author_name": m.author.get_full_name() or m.author.username,
- "avatar": m.author.avatar.url if hasattr(m.author, 'avatar') and m.author.avatar else None,
- "content": m.content,
- "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,
- "attachments": [
- {
- "id": att.id,
- "file_url": att.file.url if att.file else None,
- "file_name": att.file.name.split('/')[-1] if att.file else "file",
- "file_type": att.file_type or "unknown"
- }
- for att in m.attachments.all()
- ],
- "reactions_summary": m.get_reactions_summary(),
- "is_forwarded": m.is_forwarded,
- "forwarded_from": { ... } if m.is_forwarded else None,
- "reply_to": { ... } if m.reply_to else None,
- "poll": { ... } if hasattr(m, 'poll') else None,
- }
-```
-
----
-
-## 🎨 Рендеринг сообщений
-
-### `static/js/components/messageRenderer.js`
-
-#### Класс `MessageRenderer`:
-
-```javascript
-class MessageRenderer {
- constructor(config) {
- this.containerId = config.containerId;
- this.currentUserId = config.currentUserId;
- this.currentUserAvatar = config.currentUserAvatar;
- this.profileUrl = config.profileUrl;
- this.detailUrlTemplate = config.detailUrlTemplate;
- }
-
- /**
- * Рендерит массив сообщений (с разделителями дней)
- */
- renderMessages(messages) {
- const container = document.getElementById(this.containerId);
-
- let lastDay = null;
- messages.forEach(msg => {
- // Добавляем разделитель дня если нужно
- const msgDate = new Date(msg.created_ts);
- const msgDay = this.formatDay(msgDate);
-
- if (msgDay !== lastDay) {
- this.addDayDivider(msgDay, container);
- lastDay = msgDay;
- }
-
- this.renderMessage(msg, container);
- });
- }
-
- /**
- * Рендерит одно сообщение
- */
- renderMessage(msg, container) {
- // Проверка на дубликаты
- const existingMessage = container.querySelector(`[data-message-id="${msg.id}"]`);
- if (existingMessage) {
- console.log('Message already exists, skipping:', msg.id);
- return;
- }
-
- const isOwn = msg.author_id === this.currentUserId;
- const messageHtml = this.buildMessageHtml(msg, isOwn);
-
- // Добавляем в конец контейнера
- container.insertAdjacentHTML('beforeend', messageHtml);
- }
-}
-```
-
-#### Структура HTML сообщения:
-
-```html
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
...
-
-
-
...
-
-
- Привет, как дела?
-
-
-
(изменено)
-
-
-
...
-
-
-
...
-
-
-
-
...
-
-
-
-
...
-
-```
-
----
-
-## 🔄 Поток данных
-
-### 1. Начальная загрузка страницы
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ HTTP GET /communications/chats/123/ │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ ChatDetailView.get() │
-│ ├─ get_queryset() → Проверка доступа │
-│ ├─ get_context_data() │
-│ │ ├─ messages = последние 50 (НЕ ИСПОЛЬЗУЕТСЯ) │
-│ │ ├─ has_more = True/False │
-│ │ ├─ last_read_at │
-│ │ └─ first_unread_id │
-│ └─ render(chat_detail.html) │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ HTML с пустым контейнером #chatScroll │
-│ + индикатор загрузки #initialLoader │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ JS: DOMContentLoaded │
-│ ├─ chatDetail.js → initWhenReady() │
-│ ├─ Чтение config из data-атрибутов │
-│ └─ waitForWebSocket() │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ base.html → initUserWebSocket() │
-│ ├─ ws = new WebSocket('ws://host/ws/') │
-│ └─ dispatchEvent('user:ws-ready') │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ chatDetail.js → initializeComponents() │
-│ ├─ FormManager │
-│ ├─ MarkRead │
-│ ├─ Composer │
-│ ├─ HistoryLoader │
-│ ├─ MessageRenderer │
-│ └─ userWs.openChat(chatId, true) │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ WebSocket → { action: 'open_chat', chat_id: 123 } │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Backend: ChatConsumer.connect() │
-│ ├─ _send_initial_messages() │
-│ │ └─ _get_initial_messages(limit=50) │
-│ └─ send_json({ type: 'initial_messages', messages: [...] }) │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ userWebSocket.js → handleInitialMessages() │
-│ └─ messageRenderer.renderMessage() для каждого сообщения │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ messageRenderer.js → renderMessage() │
-│ ├─ Проверка дубликатов │
-│ ├─ Добавление разделителей дней │
-│ ├─ buildMessageHtml() │
-│ └─ container.insertAdjacentHTML('beforeend', html) │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ DOM обновлён → сообщения видны пользователю │
-│ ├─ Скролл вниз │
-│ ├─ Удаление #initialLoader │
-│ ├─ dispatchEvent('chat:initial-messages-loaded') │
-│ └─ Инициализация poll виджетов │
-└─────────────────────────────────────────────────────────────────┘
-```
-
-### 2. Получение нового сообщения
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ Другой пользователь отправляет сообщение │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Backend: ChatConsumer → channel_layer.group_send() │
-│ → broadcast в chat_123 │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ WebSocket → { type: 'new_message', message: {...} } │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ userWebSocket.js → handleNewMessage() │
-│ └─ messageRenderer.renderMessage(message) │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ DOM обновлён → новое сообщение появилось в чате │
-│ ├─ Автоскролл (если внизу) │
-│ └─ Отметка как прочитанное (если в viewport) │
-└─────────────────────────────────────────────────────────────────┘
-```
-
-### 3. Загрузка истории (скролл вверх)
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ Пользователь скроллит вверх │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ chatHistoryLoader.js → IntersectionObserver срабатывает │
-│ └─ loadMoreMessages() │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ API Request: GET /api/v1/chats/123/messages/ │
-│ ?before_id=456&limit=50 │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Backend: ChatMessagesAPIView.get() │
-│ └─ Возвращает { messages: [...], has_more: true } │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ chatHistoryLoader.js → прикрепляет сообщения в начало │
-│ └─ messageRenderer.prependMessage() для каждого │
-└───────────────────────────────┬─────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ DOM обновлён → старые сообщения видны │
-│ └─ Сохранение позиции скролла │
-└─────────────────────────────────────────────────────────────────┘
-```
-
----
-
-## 📊 Компоненты и их взаимодействие
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ chatDetail.js │
-│ (Оркестратор компонентов) │
-└────────────────┬────────────────────────────────┬───────────────┘
- │ │
- ┌───────────▼──────────┐ ┌──────────▼──────────┐
- │ userWebSocket.js │◄────────┤ messageRenderer.js │
- │ (WebSocket соединение)│ │ (Рендеринг HTML) │
- └───────────┬──────────┘ └─────────────────────┘
- │
- ┌───────────▼──────────┐
- │ chatComposer.js │
- │ (Форма отправки) │
- └──────────────────────┘
- │
- ┌───────────▼──────────┐
- │ chatMarkRead.js │
- │ (Отметка прочитанных)│
- └──────────────────────┘
- │
- ┌───────────▼──────────┐
- │chatHistoryLoader.js │
- │ (Загрузка истории) │
- └──────────────────────┘
-```
-
----
-
-## 🔑 Ключевые особенности
-
-### 1. ✅ Отключен серверный рендеринг сообщений
-- Раньше: Django рендерил `messages` через `{% for message in messages %}`
-- Сейчас: WebSocket отправляет `initial_messages` после подключения
-- Преимущество: Единый механизм рендеринга (JS), меньше дублирования кода
-
-### 2. ✅ Предотвращение дубликатов
-```javascript
-const existingMessage = container.querySelector(`[data-message-id="${msg.id}"]`);
-if (existingMessage) {
- console.log('Message already exists, skipping:', msg.id);
- return;
-}
-```
-
-### 3. ✅ Автоматические разделители дней
-```javascript
-let lastDay = null;
-messages.forEach(msg => {
- const msgDay = this.formatDay(msgDate);
- if (msgDay !== lastDay) {
- this.addDayDivider(msgDay, container);
- lastDay = msgDay;
- }
- this.renderMessage(msg, container);
-});
-```
-
-### 4. ✅ Ленивая загрузка истории
-- IntersectionObserver отслеживает элемент `.history-loader`
-- При видимости → загружает старые сообщения
-- Сохраняет позицию скролла
-
-### 5. ✅ Real-time обновления
-- Новые сообщения → `new_message`
-- Редактирование → `message_updated`
-- Удаление → `message_deleted`
-- Реакции → `reaction_added/removed`
-- Голосования → `poll_update`
-- Индикатор печати → `typing_start/stop`
-
----
-
-## 🛠️ Порядок инициализации
-
-```
-1. HTTP Request → ChatDetailView
-2. Render HTML Template
-3. DOM Ready
-4. Load JS Modules (chatDetail.js, components/*)
-5. Read Config from data-attributes
-6. Wait for WebSocket Ready (user:ws-ready event)
-7. Initialize Components:
- ├─ FormManager
- ├─ MarkRead
- ├─ Composer
- ├─ HistoryLoader
- └─ MessageRenderer
-8. Configure WebSocket with components
-9. Open Chat: userWs.openChat(chatId, true)
-10. WebSocket → Backend: { action: 'open_chat' }
-11. Backend → WebSocket: { type: 'initial_messages' }
-12. Render Messages → DOM
-13. Remove #initialLoader
-14. Scroll to Bottom
-15. Initialize Polls
-16. Ready! ✓
-```
-
----
-
-## 📝 Итоговая схема
-
-```
-┌──────────────────────────────────────────────────────────────────────┐
-│ СТРАНИЦА ЧАТА │
-├──────────────────────────────────────────────────────────────────────┤
-│ │
-│ ┌─────────────────────────────────────────────────────────────┐ │
-│ │ 1. DJANGO VIEW (Backend) │ │
-│ │ - Проверка доступа │ │
-│ │ - Подготовка контекста (без messages) │ │
-│ │ - Рендеринг HTML шаблона │ │
-│ └────────────────────────┬────────────────────────────────────┘ │
-│ │ │
-│ ▼ │
-│ ┌─────────────────────────────────────────────────────────────┐ │
-│ │ 2. HTML TEMPLATE │ │
-│ │ - Пустой контейнер #chatScroll │ │
-│ │ - Индикатор загрузки #initialLoader │ │
-│ │ - Data-атрибуты с конфигом │ │
-│ │ - Форма композера #chatForm │ │
-│ └────────────────────────┬────────────────────────────────────┘ │
-│ │ │
-│ ▼ │
-│ ┌─────────────────────────────────────────────────────────────┐ │
-│ │ 3. JAVASCRIPT INIT (chatDetail.js) │ │
-│ │ - Загрузка модулей │ │
-│ │ - Ожидание WebSocket │ │
-│ │ - Инициализация компонентов │ │
-│ └────────────────────────┬────────────────────────────────────┘ │
-│ │ │
-│ ▼ │
-│ ┌─────────────────────────────────────────────────────────────┐ │
-│ │ 4. WEBSOCKET CONNECTION (userWebSocket.js) │ │
-│ │ - openChat(chatId, loadHistory=true) │ │
-│ │ - Backend → initial_messages │ │
-│ └────────────────────────┬────────────────────────────────────┘ │
-│ │ │
-│ ▼ │
-│ ┌─────────────────────────────────────────────────────────────┐ │
-│ │ 5. MESSAGE RENDERING (messageRenderer.js) │ │
-│ │ - renderMessages(messages) │ │
-│ │ - Разделители дней │ │
-│ │ - Проверка дубликатов │ │
-│ │ - Вставка в DOM │ │
-│ └─────────────────────────────────────────────────────────────┘ │
-│ │
-│ ┌─────────────────────────────────────────────────────────────┐ │
-│ │ 6. REAL-TIME UPDATES │ │
-│ │ - new_message → добавить │ │
-│ │ - message_updated → обновить │ │
-│ │ - message_deleted → удалить │ │
-│ │ - reaction_added/removed → обновить реакции │ │
-│ │ - poll_update → обновить голосование │ │
-│ └─────────────────────────────────────────────────────────────┘ │
-│ │
-└──────────────────────────────────────────────────────────────────────┘
-```
-
----
-
-## 📚 Связанные документы
-
-- `CHAT_RENDERING_ANALYSIS.md` - Подробный анализ архитектуры рендеринга
-- `WEBSOCKET_UNIFIED_MIGRATION.md` - Миграция на единое WebSocket соединение
-- `CHAT_CACHING_FIX.md` - Отключение кэширования страницы чата
-- `MESSAGE_EDITING_ANALYSIS.md` - Механизм редактирования сообщений
-- `POLLS_IMPLEMENTATION.md` - Реализация голосований
-
----
-
-**Документ обновлён:** 4 декабря 2025 г.
diff --git a/DYNAMIC_MEDIA_URLS.md b/DYNAMIC_MEDIA_URLS.md
deleted file mode 100644
index 2f671d5f..00000000
--- a/DYNAMIC_MEDIA_URLS.md
+++ /dev/null
@@ -1,130 +0,0 @@
-# Динамическая генерация ссылок на медиа
-
-## Проблема
-При работе с несколькими доменами (локальный и глобальный) статические ссылки на медиа-файлы работали только для одного домена.
-
-## Решение
-Реализовали динамическую генерацию ссылок на медиа на основе текущего домена запроса.
-
-## Что изменилось
-
-### 1. Context Processor
-**Файл:** `backend/core/context_processors.py`
-
-Добавлен context processor `media_domain()`, который:
-- Определяет текущий домен из запроса (`request.get_host()`)
-- Формирует динамический `MEDIA_URL_DYNAMIC` с учётом протокола и домена
-- Добавляет переменные в контекст всех шаблонов:
- - `MEDIA_URL_DYNAMIC` — полный URL для медиа (например: `http://localhost:9000/media/` или `https://example.com/media/`)
- - `CURRENT_DOMAIN` — текущий домен
- - `CURRENT_SCHEME` — протокол (http/https)
-
-### 2. Настройки Django
-**Файл:** `backend/eusrr_backend/settings.py`
-
-Добавлен context processor в `TEMPLATES`:
-```python
-"context_processors": [
- # ...
- "core.context_processors.media_domain",
-],
-```
-
-### 3. Обновлённые шаблоны
-Все шаблоны обновлены для использования динамических ссылок:
-
-#### `backend/templates/feed/_feed_cards.html`
-- Изображения постов
-- Аватары авторов
-- Добавлен `loading="lazy"` и `decoding="async"` для оптимизации
-
-#### `backend/templates/feed/feed_list.html`
-- Аватары новых сотрудников
-
-#### `backend/templates/feed/post_detail.html`
-- Изображения постов в детальном виде
-- Аватары авторов
-
-## Логика обработки URL
-
-Шаблоны проверяют формат URL и обрабатывают его соответственно:
-
-```django
-{% if img_url|slice:":7" == "/media/" %}
- {# Относительный путь /media/... → добавляем домен #}
-
-{% elif img_url|slice:":1" == "/" %}
- {# Абсолютный путь /... → добавляем схему и домен #}
-
-{% else %}
- {# Уже полный URL или внешняя ссылка → используем как есть #}
-
-{% endif %}
-```
-
-## Примеры работы
-
-### Локальный домен (localhost:9000)
-```
-Запрос: http://localhost:9000/feed/
-MEDIA_URL_DYNAMIC: http://localhost:9000/media/
-
-Файл: /media/feed/images/photo.jpg
-Результат: http://localhost:9000/media/feed/images/photo.jpg
-```
-
-### Глобальный домен (example.com)
-```
-Запрос: https://example.com/feed/
-MEDIA_URL_DYNAMIC: https://example.com/media/
-
-Файл: /media/feed/images/photo.jpg
-Результат: https://example.com/media/feed/images/photo.jpg
-```
-
-## Преимущества
-
-✅ **Автоматическая адаптация** — один код работает на всех доменах
-✅ **Без перезапуска** — не нужно менять настройки при переключении доменов
-✅ **SEO-дружественные URL** — правильные абсолютные ссылки
-✅ **Поддержка HTTPS/HTTP** — автоматически определяется из запроса
-✅ **Оптимизация загрузки** — добавлены атрибуты `loading="lazy"` и `decoding="async"`
-
-## Дальнейшие улучшения
-
-### Возможные оптимизации:
-1. **CDN интеграция** — добавить отдельные CDN домены для статики
-2. **Оптимизация изображений** — генерация thumbnails разных размеров
-3. **WebP/AVIF форматы** — современные форматы для уменьшения размера
-4. **Кэширование** — добавить Cache-Control заголовки
-5. **Версионирование** — добавить версии к URL для инвалидации кэша
-
-### Пример с CDN:
-```python
-def media_domain(request):
- host = request.get_host().split(':')[0]
-
- # Определяем CDN на основе домена
- if host in ['localhost', '127.0.0.1']:
- cdn_url = f"{request.scheme}://{request.get_host()}/media/"
- elif 'dev' in host:
- cdn_url = "https://cdn-dev.example.com/media/"
- else:
- cdn_url = "https://cdn.example.com/media/"
-
- return {'MEDIA_URL_DYNAMIC': cdn_url}
-```
-
-## Тестирование
-
-Проверьте работу на обоих доменах:
-1. Откройте ленту на `http://localhost:9000/feed/`
-2. Откройте ленту на `https://your-domain.com/feed/`
-3. Убедитесь, что изображения загружаются корректно в обоих случаях
-4. Проверьте в DevTools, что URLs формируются правильно
-
-## Откат изменений
-
-Если нужно вернуться к старому поведению:
-1. Удалить `core.context_processors.media_domain` из `settings.py`
-2. В шаблонах заменить `{{ MEDIA_URL_DYNAMIC }}{{ img_url|slice:"7:" }}` на `{{ img_url }}`
diff --git a/FEED_AJAX_REFACTORING.md b/FEED_AJAX_REFACTORING.md
deleted file mode 100644
index 88d4f25f..00000000
--- a/FEED_AJAX_REFACTORING.md
+++ /dev/null
@@ -1,249 +0,0 @@
-# Рефакторинг ленты новостей на AJAX
-
-## 📋 Что было сделано
-
-Лента новостей переделана с серверного рендеринга на клиентскую загрузку через API, по аналогии с модалом просмотра публикации.
-
-## 🎯 Преимущества
-
-### 1. **Правильные URL медиа-файлов**
-- ✅ Изображения постов отображаются с правильным доменом
-- ✅ Аватары авторов загружаются корректно
-- ✅ Работает одинаково на localhost и production
-
-### 2. **Unified архитектура**
-```
-Модал публикации → API через fetch (из браузера)
-Лента новостей → API через fetch (из браузера)
- ↓
- Одинаковый механизм!
-```
-
-### 3. **Бесконечная прокрутка (Infinite Scroll)**
-- Автоматическая подгрузка постов при прокрутке
-- Без перезагрузки страницы
-- Плавный UX
-
-### 4. **Производительность**
-- Первая загрузка быстрее (нет серверного рендеринга)
-- Меньше нагрузки на backend
-- Кэширование на уровне API
-
-## 🏗️ Архитектура
-
-### До:
-```
-Browser → Django View → ApiClient → API (127.0.0.1)
- ↓
- Проблема с доменом!
- медиа URL: http://127.0.0.1/media/...
-```
-
-### После:
-```
-Browser → API напрямую (corp.robotail.local)
- ↓
- request.get_host() = 'corp.robotail.local'
- ↓
- медиа URL: https://corp.robotail.local/media/...
-```
-
-## 📁 Файлы
-
-### Новые:
-- `backend/static/js/components/feedList.js` - модуль загрузки ленты
-
-### Изменены:
-- `backend/templates/feed/feed_list.html` - использует feedList.js
-- `backend/feed/views_front.py` - упрощен, только новые сотрудники
-
-### Удалено:
-- Серверный рендеринг постов через `_feed_cards.html`
-- Пагинация на стороне сервера
-- Сложная логика преобразования URL
-
-## 🔧 Использование
-
-### Базовая инициализация:
-```javascript
-import { FeedList } from '/static/js/components/feedList.js';
-
-const feed = new FeedList({
- containerId: 'feedList',
- apiUrl: '/api/v1/posts/',
- params: { type: 'company' }
-});
-```
-
-### С кастомными параметрами:
-```javascript
-// Лента отдела
-const deptFeed = new FeedList({
- containerId: 'deptFeedList',
- apiUrl: '/api/v1/posts/',
- params: {
- type: 'department',
- department: 5
- }
-});
-```
-
-### Методы:
-```javascript
-feed.refresh(); // Перезагрузить ленту
-feed.loadPosts(1); // Загрузить страницу
-feed.currentPage; // Текущая страница
-feed.hasMore; // Есть ли еще посты
-```
-
-## 🚀 Функционал
-
-### Загрузка постов:
-- ✅ Первая страница при инициализации
-- ✅ Автоматическая подгрузка при прокрутке (500px до конца)
-- ✅ Индикатор загрузки
-- ✅ Обработка ошибок
-
-### Рендеринг:
-- ✅ Карточка поста с заголовком, телом, изображением
-- ✅ Аватар и имя автора (кликабельные)
-- ✅ Дата публикации
-- ✅ Значок закрепления
-- ✅ Лайки и комментарии
-- ✅ Открытие модала при клике
-
-### Поиск:
-- ✅ Фильтрация по заголовку
-- ✅ Фильтрация по автору
-- ✅ Фильтрация по содержанию
-- ✅ Работает через data-атрибуты
-
-## 🔍 Техническая информация
-
-### API Response:
-```json
-{
- "count": 42,
- "next": "https://corp.robotail.local/api/v1/posts/?page=2",
- "previous": null,
- "results": [
- {
- "id": 1,
- "title": "Заголовок",
- "body": "Содержание",
- "image": "https://corp.robotail.local/media/posts/img.jpg",
- "author": {
- "id": 5,
- "full_name": "Иван Иванов",
- "avatar": "https://corp.robotail.local/media/avatars/5.jpg"
- },
- "created_at_display": "05.12.2025 10:30",
- "likes_count": 10,
- "comments_count": 3,
- "is_liked": false,
- "pinned": false
- }
- ]
-}
-```
-
-### Структура карточки:
-```html
-
-
-
-
-
-
-
-
-
-
-```
-
-## 🐛 Debugging
-
-### Включить отладку:
-```javascript
-// В feedList.js временно добавить:
-console.log('Loading posts:', params);
-console.log('Response:', data);
-```
-
-### Проверить запросы:
-```
-DevTools → Network → XHR
-Ищем: /api/v1/posts/?type=company&page=1
-```
-
-### Проверить медиа URL:
-```javascript
-// В консоли:
-document.querySelectorAll('.feed-post img').forEach(img => {
- console.log(img.src);
-});
-
-// Должны быть: https://corp.robotail.local/media/...
-// НЕ должны быть: http://127.0.0.1/media/...
-```
-
-## 📊 Производительность
-
-### Метрики:
-- **Первая загрузка**: ~200-300ms (без серверного рендеринга)
-- **Подгрузка страницы**: ~100-150ms (только JSON)
-- **Рендеринг 20 постов**: ~50ms (native DOM)
-
-### Оптимизации:
-- ✅ Ленивая загрузка изображений (`loading="lazy"`)
-- ✅ RequestAnimationFrame для скролла
-- ✅ Debounce на проверку позиции
-- ✅ Переиспользование API cache
-
-## 🔄 Миграция на production
-
-### 1. Деплой кода:
-```bash
-git pull origin master
-```
-
-### 2. Проверка:
-- Открыть `/feed/`
-- Проверить DevTools → Network
-- Убедиться что запросы идут на правильный домен
-
-### 3. Откат (если что-то не так):
-```bash
-git revert HEAD
-```
-
-## ✅ Чеклист тестирования
-
-- [ ] Лента загружается при открытии страницы
-- [ ] Infinite scroll работает (прокрутка вниз подгружает)
-- [ ] Медиа отображаются корректно
-- [ ] Клик по посту открывает модал
-- [ ] Лайки работают
-- [ ] Поиск фильтрует посты
-- [ ] Создание поста обновляет ленту
-- [ ] Работает на мобильных устройствах
-
-## 📚 Связанные файлы
-
-- `postDetailModal.js` - модал просмотра (использует ту же схему)
-- `createPostModal.js` - модал создания
-- `feedLikes.js` - обработка лайков
-- `feedComments.js` - обработка комментариев
-- `listFilter.js` - поиск и фильтрация
-
-## 🎓 Выводы
-
-Переход на AJAX загрузку решил проблему с медиа URL и улучшил UX:
-1. Медиа отдаются с правильным доменом автоматически
-2. Нет нужды в сложной логике на сервере
-3. Unified подход для модалов и ленты
-4. Бесконечная прокрутка из коробки
diff --git a/IP_REGISTRATION_RESTRICTIONS.md b/IP_REGISTRATION_RESTRICTIONS.md
deleted file mode 100644
index 10592bf7..00000000
--- a/IP_REGISTRATION_RESTRICTIONS.md
+++ /dev/null
@@ -1,290 +0,0 @@
-# Ограничение доступа к регистрации по IP
-
-## Описание
-
-Реализована система ограничения доступа к регистрации новых пользователей по IP-адресу через middleware. Это позволяет разрешить регистрацию только из локальной сети или конкретных IP-адресов.
-
-## Файлы
-
-### 1. `backend/common/ip_restrictions.py`
-
-Утилиты для проверки IP-адресов:
-
-- **`get_client_ip(request)`** - получает реальный IP клиента (учитывает прокси)
-- **`is_local_ip(ip_address)`** - проверяет, является ли IP локальным
-- **`is_ip_allowed(ip_address, allowed_networks)`** - проверяет доступ для IP
-
-### 2. `backend/eusrr_backend/middleware.py`
-
-Добавлен класс `RegistrationIPRestrictionMiddleware` для проверки IP при регистрации.
-
-### 3. Настройка в `backend/eusrr_backend/settings.py`
-
-В `MIDDLEWARE` добавлена строка:
-```python
-"eusrr_backend.middleware.RegistrationIPRestrictionMiddleware",
-```
-
-Добавлена настройка `REGISTRATION_ALLOWED_IPS`:
-```python
-REGISTRATION_ALLOWED_IPS = None # По умолчанию только локальные IP
-```
-
-## Конфигурация
-
-### По умолчанию (только локальные IP)
-
-```python
-REGISTRATION_ALLOWED_IPS = None
-```
-
-Разрешены только:
-- `127.0.0.0/8` (localhost)
-- `10.0.0.0/8` (частная сеть класса A)
-- `172.16.0.0/12` (частная сеть класса B)
-- `192.168.0.0/16` (частная сеть класса C)
-- `::1` (IPv6 localhost)
-- `fe80::/10` (IPv6 link-local)
-
-### Разрешить все IP
-
-```python
-REGISTRATION_ALLOWED_IPS = ['*']
-```
-
-### Разрешить конкретные сети (CIDR)
-
-```python
-REGISTRATION_ALLOWED_IPS = [
- '192.168.1.0/24', # Локальная сеть офиса
- '10.0.0.0/8', # Корпоративная сеть
- '172.16.100.0/24', # VPN сеть
-]
-```
-
-### Разрешить конкретные IP-адреса
-
-```python
-REGISTRATION_ALLOWED_IPS = [
- '192.168.1.100',
- '192.168.1.101',
- '203.0.113.42',
-]
-```
-
-### Комбинированный подход
-
-```python
-REGISTRATION_ALLOWED_IPS = [
- '192.168.1.0/24', # Вся локальная сеть
- '203.0.113.42', # Конкретный внешний IP
- '198.51.100.0/28', # Диапазон внешних IP
-]
-```
-
-## Поведение при блокировке
-
-### Для обычных view (HTML)
-
-При попытке доступа с неразрешенного IP пользователь увидит:
-
-```html
-
403 Forbidden
-
Регистрация доступна только из локальной сети.
-
Ваш IP: 203.0.113.50
-```
-
-### Для API endpoints (JSON)
-
-При попытке доступа с неразрешенного IP API вернет:
-
-```json
-{
- "detail": "Регистрация доступна только из локальной сети.",
- "client_ip": "203.0.113.50"
-}
-```
-
-HTTP статус: `403 Forbidden`
-
-## Работа с прокси
-
-Функция `get_client_ip()` корректно определяет реальный IP клиента даже при использовании прокси-серверов:
-
-1. Проверяет заголовок `X-Forwarded-For`
-2. Берет первый IP из списка (клиентский IP)
-3. Если заголовка нет, использует `REMOTE_ADDR`
-
-### Настройка для работы за Nginx
-
-Добавьте в конфигурацию Nginx:
-
-```nginx
-location / {
- proxy_pass http://127.0.0.1:9000;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header Host $host;
-}
-```
-
-## Тестирование
-
-### Проверка локального доступа
-
-```bash
-curl http://localhost:9000/api/v1/auth/register/ \
- -H "Content-Type: application/json" \
- -d '{"email":"test@example.com","password":"test123",...}'
-```
-
-Должно работать с `127.0.0.1`.
-
-### Проверка блокировки (эмуляция внешнего IP)
-
-```bash
-curl http://localhost:9000/api/v1/auth/register/ \
- -H "Content-Type: application/json" \
- -H "X-Forwarded-For: 203.0.113.50" \
- -d '{"email":"test@example.com","password":"test123",...}'
-```
-
-Должно вернуть `403 Forbidden` если `REGISTRATION_ALLOWED_IPS` не содержит этот IP.
-
-### Тестирование в коде
-
-```python
-from common.ip_restrictions import is_local_ip, is_ip_allowed
-
-# Проверка локальных IP
-assert is_local_ip('127.0.0.1') == True
-assert is_local_ip('192.168.1.100') == True
-assert is_local_ip('10.0.0.5') == True
-assert is_local_ip('203.0.113.50') == False
-
-# Проверка с настройками
-from django.conf import settings
-settings.REGISTRATION_ALLOWED_IPS = ['192.168.1.0/24']
-
-assert is_ip_allowed('192.168.1.50') == True
-assert is_ip_allowed('192.168.2.50') == False
-```
-
-## Безопасность
-
-### Рекомендации
-
-1. **Для production**: используйте конкретные сети, не `['*']`
-2. **За прокси**: убедитесь, что прокси правильно передает заголовки
-3. **IPv6**: модуль поддерживает IPv6, учтите это в конфигурации
-4. **Логирование**: при необходимости добавьте логирование заблокированных попыток
-
-### Обход защиты
-
-Потенциальные проблемы:
-
-1. **Подмена заголовков**: если прокси настроен неправильно, злоумышленник может подменить `X-Forwarded-For`
-2. **VPN/Proxy клиента**: пользователь может использовать VPN с локальным IP
-
-### Дополнительная защита
-
-Рекомендуется комбинировать с:
-- Rate limiting (throttling в DRF)
-- CAPTCHA для регистрации
-- Email верификация (уже реализована)
-- Логирование попыток регистрации
-
-## Примеры использования в других view
-
-Если нужно ограничить доступ к другим URL по IP, можно создать дополнительные middleware или расширить существующий:
-
-```python
-class CustomIPRestrictionMiddleware:
- """Ограничение по IP для произвольных URL"""
-
- RESTRICTED_PATHS = (
- "/admin/", # Например, админка только из локальной сети
- "/api/v1/sensitive/", # Чувствительные API
- )
-
- def __call__(self, request):
- from common.ip_restrictions import get_client_ip, is_ip_allowed
-
- path = request.path_info
-
- if any(path.startswith(p) for p in self.RESTRICTED_PATHS):
- client_ip = get_client_ip(request)
- if not is_ip_allowed(client_ip):
- # Блокируем доступ
- ...
-
- return self.get_response(request)
-```
-
-## Отключение ограничений
-
-### Временное отключение (для разработки)
-
-В `settings.py`:
-
-```python
-REGISTRATION_ALLOWED_IPS = ['*'] # Разрешить все IP
-```
-
-### Полное отключение
-
-В `settings.py` закомментируйте middleware:
-
-```python
-MIDDLEWARE = [
- # ... другие middleware ...
- # "eusrr_backend.middleware.RegistrationIPRestrictionMiddleware", # Отключено
- "eusrr_backend.middleware.CacheControlMiddleware",
-]
-```
-
-## Мониторинг
-
-### Добавление логирования
-
-Можно расширить `backend/eusrr_backend/middleware.py`:
-
-```python
-def __call__(self, request):
- import logging
- logger = logging.getLogger(__name__)
-
- path = request.path_info
-
- if not any(path.startswith(p) for p in self.RESTRICTED_PATHS):
- return self.get_response(request)
-
- client_ip = get_client_ip(request)
-
- if not is_ip_allowed(client_ip):
- logger.warning(
- f'Registration blocked for IP {client_ip} '
- f'(path: {path}, user-agent: {request.META.get("HTTP_USER_AGENT")})'
- )
- # ... блокировка ...
-
- logger.info(f'Registration allowed for IP {client_ip}')
- return self.get_response(request)
-```
-
-### Метрики
-
-Можно отслеживать:
-- Количество заблокированных попыток регистрации
-- IP-адреса, которые часто блокируются
-- Географию заблокированных запросов
-
-## Вопросы и поддержка
-
-При возникновении проблем проверьте:
-
-1. Правильность настройки `REGISTRATION_ALLOWED_IPS` в settings.py
-2. Корректность заголовков прокси (если используется)
-3. Middleware включен в `MIDDLEWARE` в settings.py
-4. Формат IP-адресов в конфигурации (должны быть валидными CIDR или IP)
-5. Логи Django на наличие ошибок в модуле `eusrr_backend.middleware`
diff --git a/IP_RESTRICTIONS_README.md b/IP_RESTRICTIONS_README.md
deleted file mode 100644
index 3b31d071..00000000
--- a/IP_RESTRICTIONS_README.md
+++ /dev/null
@@ -1,96 +0,0 @@
-# 🔒 IP-ограничения для регистрации - Краткая инструкция
-
-## ✅ Что сделано
-
-Добавлен **middleware** для ограничения доступа к регистрации по IP-адресу.
-
-## 📁 Изменённые файлы
-
-1. **`backend/eusrr_backend/middleware.py`** - добавлен `RegistrationIPRestrictionMiddleware`
-2. **`backend/eusrr_backend/settings.py`** - middleware включен в `MIDDLEWARE`
-3. **`backend/common/ip_restrictions.py`** - утилиты для проверки IP
-
-## ⚙️ Настройка
-
-В `backend/eusrr_backend/settings.py` найдите:
-
-```python
-# IP ОГРАНИЧЕНИЯ ДЛЯ РЕГИСТРАЦИИ
-REGISTRATION_ALLOWED_IPS = None # По умолчанию только локальные IP
-```
-
-### Варианты настройки:
-
-```python
-# 1. Только локальные IP (по умолчанию)
-REGISTRATION_ALLOWED_IPS = None
-
-# 2. Разрешить все IP (для разработки)
-REGISTRATION_ALLOWED_IPS = ['*']
-
-# 3. Разрешить конкретные сети
-REGISTRATION_ALLOWED_IPS = [
- '192.168.1.0/24', # Локальная сеть офиса
- '10.0.0.0/8', # Корпоративная сеть
-]
-
-# 4. Разрешить конкретные IP
-REGISTRATION_ALLOWED_IPS = ['192.168.1.100', '203.0.113.42']
-```
-
-## 🎯 Как работает
-
-**По умолчанию (None):**
-- ✅ Разрешены: `127.0.0.1`, `192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`
-- ❌ Заблокированы: все внешние IP
-
-**Блокируемые URL:**
-- `/auth/register/` (веб-форма)
-- `/api/v1/auth/register/` (API)
-
-**Ответ при блокировке:**
-- API: `{"detail": "Регистрация доступна только из локальной сети.", "client_ip": "..."}`
-- Веб: Красивая страница 403 с информацией об IP
-
-## 🔧 Отключение
-
-### Временно (для разработки):
-```python
-REGISTRATION_ALLOWED_IPS = ['*']
-```
-
-### Полностью:
-В `settings.py` закомментируйте строку в `MIDDLEWARE`:
-```python
-# "eusrr_backend.middleware.RegistrationIPRestrictionMiddleware",
-```
-
-## 🧪 Тестирование
-
-```bash
-# Из локальной сети - работает
-curl http://localhost:9000/api/v1/auth/register/ \
- -H "Content-Type: application/json" \
- -d '{"email":"test@example.com",...}'
-
-# С внешнего IP - блокируется
-curl http://localhost:9000/api/v1/auth/register/ \
- -H "X-Forwarded-For: 203.0.113.50" \
- -d '{"email":"test@example.com",...}'
-```
-
-## 🌐 Работа за прокси
-
-Middleware автоматически определяет реальный IP через заголовки `X-Forwarded-For` и `X-Real-IP`.
-
-Настройте Nginx:
-```nginx
-location / {
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Real-IP $remote_addr;
-}
-```
-
-## 📚 Полная документация
-
-См. `IP_REGISTRATION_RESTRICTIONS.md`
diff --git a/MESSAGE_NEWLINES_FIX.md b/MESSAGE_NEWLINES_FIX.md
deleted file mode 100644
index 8322789c..00000000
--- a/MESSAGE_NEWLINES_FIX.md
+++ /dev/null
@@ -1,198 +0,0 @@
-# Исправление: Поддержка переносов строк в сообщениях
-
-**Дата:** 4 декабря 2025 г.
-**Проблема:** Сообщения не отображали переносы строк (многострочный текст показывался одной строкой)
-
----
-
-## 🔴 Проблема
-
-При отправке многострочных сообщений в чате (с нажатием Enter для переноса строки), текст отображался одной строкой, игнорируя все переносы.
-
-### Пример:
-
-**Ввод пользователя:**
-```
-Строка 1
-Строка 2
-Строка 3
-```
-
-**Отображалось как:**
-```
-Строка 1 Строка 2 Строка 3
-```
-
----
-
-## 🔍 Анализ
-
-### 1. Рендеринг сообщений (`messageRenderer.js`)
-
-```javascript
-// Строка 210
-${msg.content ? `
${this.escapeHtml(msg.content)}
` : ''}
-
-// Метод escapeHtml (строка 318)
-escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
-}
-```
-
-Метод `escapeHtml()` корректно экранирует HTML, но переносы строк (`\n`) остаются в виде символов, которые браузер игнорирует в обычном режиме отображения.
-
-### 2. CSS стили bubble (`chat-detail.css`)
-
-```css
-.bubble {
- border-radius: 16px;
- padding: var(--space-1) var(--space-2);
- word-break: break-word;
- /* white-space не указан - по умолчанию: normal */
- box-shadow: 0 1px 2px rgba(0, 0, 0, .06);
-}
-```
-
-По умолчанию `white-space: normal` схлопывает последовательные пробелы и переносы строк в один пробел.
-
----
-
-## ✅ Решение
-
-Добавлено CSS свойство `white-space: pre-wrap` для класса `.bubble`:
-
-```css
-.bubble {
- border-radius: 16px;
- padding: var(--space-1) var(--space-2);
- word-break: break-word;
- white-space: pre-wrap; /* ← ДОБАВЛЕНО */
- box-shadow: 0 1px 2px rgba(0, 0, 0, .06);
-}
-```
-
-### Что делает `white-space: pre-wrap`:
-
-| Свойство | Переносы строк | Пробелы | Перенос текста |
-|----------|---------------|---------|----------------|
-| `normal` (по умолчанию) | ❌ Игнорируются | ❌ Схлопываются | ✅ Есть |
-| `pre` | ✅ Сохраняются | ✅ Сохраняются | ❌ Нет |
-| **`pre-wrap`** | **✅ Сохраняются** | **✅ Сохраняются** | **✅ Есть** |
-| `pre-line` | ✅ Сохраняются | ❌ Схлопываются | ✅ Есть |
-
-`pre-wrap` — идеальный вариант для чатов:
-- ✅ Сохраняет переносы строк
-- ✅ Сохраняет множественные пробелы (для форматирования)
-- ✅ Автоматически переносит длинные строки (не вылезает за пределы)
-
----
-
-## 🎨 Результат
-
-### До исправления:
-```
-Привет! Как дела? Давно не виделись!
-```
-
-### После исправления:
-```
-Привет!
-Как дела?
-Давно не виделись!
-```
-
----
-
-## 🧪 Тестирование
-
-### Примеры для тестирования:
-
-1. **Простой перенос:**
- ```
- Строка 1
- Строка 2
- ```
-
-2. **Множественные переносы:**
- ```
- Строка 1
-
-
- Строка 2 (после двух пустых строк)
- ```
-
-3. **Длинная строка с переносом:**
- ```
- Очень длинная строка текста, которая должна автоматически перенестись на новую строку если не помещается в ширину bubble
- ```
-
-4. **Форматированный текст:**
- ```
- Список покупок:
- - Молоко
- - Хлеб
- - Масло
- ```
-
-5. **Код или табуляция:**
- ```
- function hello() {
- console.log("Hello!");
- }
- ```
-
----
-
-## 📁 Изменённые файлы
-
-- ✅ `backend/static/css/components/chat-detail.css` - добавлен `white-space: pre-wrap` для `.bubble`
-
----
-
-## 🔄 Альтернативные решения (не использовались)
-
-### Вариант 2: Конвертация `\n` в `
` в JavaScript
-
-```javascript
-// В messageRenderer.js
-formatContent(text) {
- return this.escapeHtml(text).replace(/\n/g, '
');
-}
-
-// Использование:
-${msg.content ? `
${this.formatContent(msg.content)}
` : ''}
-```
-
-**Недостатки:**
-- ❌ Не сохраняет множественные пробелы
-- ❌ Требует изменения JS кода
-- ❌ Менее гибко для будущих доработок
-
-### Вариант 3: CSS `white-space: pre-line`
-
-```css
-.bubble {
- white-space: pre-line;
-}
-```
-
-**Недостатки:**
-- ❌ Не сохраняет множественные пробелы (схлопывает в один)
-- ❌ Менее удобно для форматированного текста
-
----
-
-## 🎯 Выводы
-
-1. ✅ **CSS решение** (`white-space: pre-wrap`) — наиболее правильное и простое
-2. ✅ Не требует изменений в JavaScript
-3. ✅ Поддерживает все виды форматирования текста
-4. ✅ Совместимо с существующей логикой экранирования HTML
-5. ✅ Работает для всех типов сообщений (свои и чужие)
-
----
-
-**Статус:** ✅ Исправлено
-**Тестирование:** Требуется проверка в браузере
diff --git a/MESSAGE_RENDERING_REFACTORING.md b/MESSAGE_RENDERING_REFACTORING.md
deleted file mode 100644
index 39b1f22b..00000000
--- a/MESSAGE_RENDERING_REFACTORING.md
+++ /dev/null
@@ -1,272 +0,0 @@
-# Рефакторинг системы рендеринга сообщений
-
-## Дата: 4 декабря 2025 г.
-
-## Проблема
-
-Система рендеринга сообщений имела дублирование кода:
-- `messageRenderer.js` - рендерил новые сообщения через WebSocket
-- `chatMessageTemplates.js` - рендерил старые сообщения при скролле вверх
-
-Оба файла содержали:
-1. **Многострочные HTML-шаблоны** с переносами строк и отступами
-2. **Дублирующуюся логику** для формирования HTML
-3. **Разные подходы** к обработке данных
-
-### Симптомы
-- Пилюли сообщений растягивались по высоте из-за whitespace в HTML
-- `white-space: pre-line` сохранял переносы из исходного кода JavaScript
-- Различное отображение сообщений в зависимости от способа загрузки
-
-## Решение
-
-### 1. Единый источник правды
-**messageRenderer.js** стал единственным местом генерации HTML:
-
-```javascript
-export class MessageRenderer {
- // Полный HTML с обёрткой .msg для WebSocket
- buildMessageHtml(msg, isOwn) {
- return `
${this.buildMessageInnerHtml(msg, isOwn)}
`;
- }
-
- // Внутренний HTML без обёртки для DOM-элементов
- buildMessageInnerHtml(msg, isOwn) {
- // Все шаблоны в одну строку без переносов
- return `${avatarHtml}
...
${rightAvatarHtml}`;
- }
-}
-```
-
-### 2. Обёртка в chatMessageTemplates.js
-**chatMessageTemplates.js** теперь использует MessageRenderer:
-
-```javascript
-import { MessageRenderer } from './messageRenderer.js';
-
-export function createMessageElement(msg, options = {}) {
- const renderer = new MessageRenderer({
- meId: options.meId,
- profileUrl: options.profileUrl,
- detailUrlTemplate: options.detailUrlTemplate,
- currentUserAvatar: options.avatarMap[options.meId]
- });
-
- const wrap = document.createElement('div');
- // Настройка атрибутов wrap...
-
- // Используем единый рендерер
- wrap.innerHTML = renderer.buildMessageInnerHtml(enrichedMsg, mine);
-
- return wrap;
-}
-```
-
-### 3. Устранение многострочных шаблонов
-
-**ДО:**
-```javascript
-const forwardedHtml = msg.is_forwarded ? `
-
-
-
-
Переслано от ${author}
-
-
` : '';
-```
-
-**ПОСЛЕ:**
-```javascript
-const forwardedHtml = msg.is_forwarded ? `
Переслано от ${msg.forwarded_from.author_name || 'Пользователя'} ${msg.forwarded_from.chat_name ? ` из «${msg.forwarded_from.chat_name}»` : ''}
${msg.forwarded_from.created_at ? `
${msg.forwarded_from.created_at}
` : ''}
` : '';
-```
-
-### 4. Рефакторинг всех компонентов
-
-Применено ко всем HTML-генераторам:
-- ✅ `forwardedHtml` - информация о пересылке
-- ✅ `replyHtml` - цитата ответа
-- ✅ `avatarHtml` - аватары пользователей
-- ✅ `rightAvatarHtml` - аватар справа (свои сообщения)
-- ✅ `attachmentsHtml` - вложения (изображения, файлы)
-- ✅ `pollHtml` - голосования/опросы
-- ✅ `bubble` - основной контейнер сообщения
-
-## Архитектура после рефакторинга
-
-```
-┌──────────────────────────────────────────────────┐
-│ messageRenderer.js │
-│ │
-│ ┌─────────────────────────────────────────┐ │
-│ │ buildMessageHtml(msg, isOwn) │ │
-│ │ → Полный HTML с обёрткой .msg │ │
-│ │ → Используется для WebSocket │ │
-│ └─────────────────────────────────────────┘ │
-│ │ │
-│ ▼ │
-│ ┌─────────────────────────────────────────┐ │
-│ │ buildMessageInnerHtml(msg, isOwn) │◄───┼─── Единый источник
-│ │ → HTML без обёртки .msg │ │ правды для всех
-│ │ → Все шаблоны в одну строку │ │ HTML-шаблонов
-│ │ → Нет whitespace из исходного кода │ │
-│ └─────────────────────────────────────────┘ │
-│ │ │ │ │
-│ ▼ ▼ ▼ │
-│ buildAttachmentHtml buildPollHtml ... │
-└──────────────────────────────────────────────────┘
- ▲
- │
- │ import & использование
- │
-┌──────────────────────────────────────────────────┐
-│ chatMessageTemplates.js │
-│ │
-│ createMessageElement(msg, options) │
-│ → Создаёт DOM элемент │
-│ → Использует MessageRenderer.buildMessageInnerHtml │
-│ → Добавляет pending статус если нужно │
-└──────────────────────────────────────────────────┘
- ▲
- │
- │ использование
- │
-┌──────────────────────────────────────────────────┐
-│ chatHistoryLoader.js │
-│ │
-│ prependMessages() → createMessageElement() │
-│ → Загрузка старых сообщений при скролле вверх │
-└──────────────────────────────────────────────────┘
-```
-
-## Поток данных
-
-### WebSocket (новые сообщения)
-```
-WebSocket event
- ↓
-chatDetail.js
- ↓
-messageRenderer.buildMessageHtml()
- ↓
-insertAdjacentHTML()
- ↓
-DOM
-```
-
-### HTTP + Скролл (старые сообщения)
-```
-Scroll вверх
- ↓
-chatHistoryLoader.loadMore()
- ↓
-fetch(/api/messages?before_id=...)
- ↓
-chatMessageTemplates.createMessageElement()
- ↓
- → MessageRenderer.buildMessageInnerHtml()
- ↓
-DOM element
- ↓
-insertBefore()
-```
-
-## Преимущества рефакторинга
-
-### ✅ Единообразие
-- Одинаковый HTML независимо от способа загрузки
-- Нет расхождений между WebSocket и HTTP
-- Единая логика обработки всех компонентов (ответы, вложения, опросы)
-
-### ✅ Производительность
-- Нет лишних whitespace в HTML
-- Компактные пилюли сообщений
-- Правильная работа `white-space: pre-line` (сохраняет только `\n` из контента)
-
-### ✅ Поддерживаемость
-- DRY (Don't Repeat Yourself)
-- Одно место для изменений
-- Легче тестировать и дебажить
-
-### ✅ Расширяемость
-- Новые типы вложений добавляются в одном месте
-- Новые поля сообщений обрабатываются единообразно
-
-## CSS изменения
-
-### _chat-detail.scss
-```scss
-.bubble {
- border-radius: 16px;
- padding: var(--space-1) var(--space-2);
- word-break: break-word;
- white-space: pre-line; /* Сохраняет \n из контента, схлопывает пробелы из HTML */
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
-
- > div {
- white-space: inherit; /* Наследование для вложенных элементов */
- }
-}
-```
-
-## Тестирование
-
-После рефакторинга проверить:
-1. ✅ Переносы строк (`\n`) отображаются корректно
-2. ✅ Высота пилюль соответствует контенту (без растягивания)
-3. ✅ Сообщения с 1 символом компактные
-4. ✅ Старые сообщения (скролл вверх) выглядят так же как новые (WebSocket)
-5. ✅ Сообщения с вложениями, опросами, ответами не имеют лишних отступов
-6. ✅ Аватары отображаются корректно
-7. ✅ Реакции работают
-8. ✅ Редактирование сообщений работает
-
-## Совместимость
-
-### Обратная совместимость
-- ✅ Все существующие функции сохранены
-- ✅ API не изменился
-- ✅ DOM-структура осталась прежней
-- ✅ CSS-классы не изменились
-
-### Зависимости
-```javascript
-// messageRenderer.js
-export class MessageRenderer { ... }
-export function createMessageRenderer(config) { ... }
-
-// chatMessageTemplates.js
-import { MessageRenderer } from './messageRenderer.js';
-import { esc } from '../utils/stringUtils.js';
-
-// chatHistoryLoader.js
-import { createMessageElement } from './chatMessageTemplates.js';
-```
-
-## Следующие шаги
-
-1. **Тестирование** - проверить все сценарии использования
-2. **Мониторинг** - отследить производительность в production
-3. **Документация** - обновить комментарии в коде
-4. **Оптимизация** - возможно кеширование MessageRenderer
-5. **Расширение** - добавить поддержку новых типов контента
-
-## Файлы изменены
-
-- ✅ `backend/static/js/components/messageRenderer.js`
- - Добавлен метод `buildMessageInnerHtml()`
- - Все шаблоны переведены в одну строку
- - Добавлена поддержка параметра `meId`
-
-- ✅ `backend/static/js/components/chatMessageTemplates.js`
- - Переписан `createMessageElement()` для использования MessageRenderer
- - Удалён дублирующийся код генерации HTML
- - Сохранена обратная совместимость API
-
-- ✅ `backend/static/scss/components/_chat-detail.scss`
- - Изменён `white-space: pre-wrap` → `pre-line`
- - Добавлено правило наследования для вложенных `div`
-
-## Авторы
-
-- Рефакторинг: GitHub Copilot + Igor
-- Дата: 4 декабря 2025 г.
diff --git a/PROCUREMENT_APPROVERS.md b/PROCUREMENT_APPROVERS.md
deleted file mode 100644
index cb03d27b..00000000
--- a/PROCUREMENT_APPROVERS.md
+++ /dev/null
@@ -1,160 +0,0 @@
-# Механизм назначения согласующих в заявках на закупку
-
-## Как это работает
-
-### 1. Определение требуемых ролей (модель `ProcurementRequest`)
-
-Когда пользователь отправляет заявку на согласование (`submit` action), система определяет какие роли нужны:
-
-```python
-# в procurement/models.py - метод get_required_approvals()
-
-cost = estimated_cost # Сумма заявки
-
-if cost < 10,000₽: # НИЗКАЯ
- → [DEPARTMENT_HEAD] # Только руководитель отдела
-
-elif cost < 100,000₽: # СРЕДНЯЯ
- → [DEPARTMENT_HEAD, # Руководитель отдела +
- FINANCE_MANAGER] # Финансовый менеджер
-
-else: # ВЫСОКАЯ (> 100K)
- → [DEPARTMENT_HEAD, # Руководитель отдела +
- FINANCE_MANAGER, # Финансовый менеджер +
- DIRECTOR] # Генеральный директор
-```
-
-### 2. Поиск конкретных людей (вьюсет `ProcurementRequestViewSet.submit()`)
-
-После определения ролей система ищет людей, занимающих эти должности:
-
-```python
-for role in required_approvals:
- approver = None
-
- if role == DEPARTMENT_HEAD:
- # ✅ Берёт руководителя из department.head
- approver = procurement_request.department.head
-
- elif role == FINANCE_MANAGER:
- # ✅ Ищет пользователя с правом change_budget
- approver = Employee.objects.filter(
- is_active=True,
- groups__permissions__codename='change_budget'
- ).first()
-
- elif role == DIRECTOR:
- # ✅ Берёт первого суперпользователя
- approver = Employee.objects.filter(
- is_superuser=True
- ).first()
-
- # Создаём запись согласования
- Approval.objects.create(
- request=procurement_request,
- approver=approver,
- role=role,
- status=ApprovalStatus.PENDING
- )
-```
-
-## Пример
-
-### Заявка на 50,000₽ от отдела IT
-
-```
-Сумма: 50,000₽
-Отдел: IT (руководитель = Петр Петров)
-
-┌─────────────────────────────────────────┐
-│ Определение ролей (get_required_approvals)
-│ 10,000 < 50,000 < 100,000
-│ → [DEPARTMENT_HEAD, FINANCE_MANAGER]
-└─────────────────────────────────────────┘
- ↓
-┌─────────────────────────────────────────┐
-│ Поиск согласующих (submit action) │
-├─────────────────────────────────────────┤
-│ DEPARTMENT_HEAD │
-│ → IT.head = Петр Петров ✓ │
-│ │
-│ FINANCE_MANAGER │
-│ → Permission(change_budget) │
-│ → Мария Сидорова ✓ │
-└─────────────────────────────────────────┘
- ↓
-┌─────────────────────────────────────────┐
-│ Создание записей согласования │
-├─────────────────────────────────────────┤
-│ Approval #1: Петр Петров (PENDING) │
-│ Approval #2: Мария Сидорова (PENDING) │
-└─────────────────────────────────────────┘
- ↓
- Уведомления
- ↙ ↖
- Петр Мария
-```
-
-## Где это конфигурируется?
-
-| Компонент | Где конфигурируется | Как |
-|-----------|-------------------|-----|
-| **Пороги сумм** | `procurement/constants.py` | APPROVAL_THRESHOLD_LOW, APPROVAL_THRESHOLD_HIGH |
-| **Руководитель отдела** | Django Admin → Departments | Выбрать `head` в модели Department |
-| **Финансовый менеджер** | Django Admin → Permissions | Дать permission `change_budget` пользователю или группе |
-| **Директор** | Django Admin → Users | Сделать пользователя суперпользователем (`is_superuser=True`) |
-
-## Потенциальные проблемы и решения
-
-### 1. "Финансовый менеджер не найдена"
-```python
-# Причина: нет пользователя с правом change_budget
-# Решение:
-Django Admin → Users → выбрать пользователя → добавить permission change_budget
-```
-
-### 2. "Директор не найден"
-```python
-# Причина: нет суперпользователей в системе
-# Решение:
-Django Admin → Users → выбрать пользователя → поставить галочку is_superuser
-```
-
-### 3. Разные согласующие для разных отделов
-```python
-# Текущая реализация: один финменеджер и один директор на всю компанию
-# Чтобы сделать разных:
-# 1. Создать модель ApprovalChain или DepartmentApprovalPolicy
-# 2. Связать со своими финменеджерами и директорами
-# 3. Изменить логику поиска в submit action
-```
-
-## API для отладки
-
-### Проверить какие роли требуются для заявки
-```bash
-GET /api/procurement/requests/{id}/
-# Ответ содержит поле "required_approvals" если его добавить в сериализатор
-```
-
-### Узнать кто назначен согласующим
-```bash
-GET /api/procurement/requests/{id}/
-# approvals: [
-# { id, approver, role, status, comment }
-# ]
-```
-
-## Параметры для настройки
-
-```python
-# backend/procurement/constants.py
-
-APPROVAL_THRESHOLD_LOW = 10_000 # ← Можно менять
-APPROVAL_THRESHOLD_HIGH = 50_000 # ← Можно менять
-
-# Порог | Рол требующиеся
-# < 10K | Руководитель отдела
-# 10K - 50K | Руководитель + Финансы
-# > 50K | Руководитель + Финансы + Директор
-```
diff --git a/PROCUREMENT_IMPLEMENTATION_PLAN.md b/PROCUREMENT_IMPLEMENTATION_PLAN.md
deleted file mode 100644
index 1f4f0e84..00000000
--- a/PROCUREMENT_IMPLEMENTATION_PLAN.md
+++ /dev/null
@@ -1,1568 +0,0 @@
-# План Разработки Модуля "Закупки и Инвентарь" (Procurement)
-
-**Дата начала:** 8 декабря 2025
-**Статус:** 🚀 В разработке
-**Версия:** 1.0
-
----
-
-## 📋 Содержание
-
-1. [Обзор Проекта](#обзор-проекта)
-2. [Архитектурные Решения](#архитектурные-решения)
-3. [Архитектура и Модели](#архитектура-и-модели)
-4. [Права Доступа](#права-доступа)
-5. [Workflow Согласования](#workflow-согласования)
-6. [API Endpoints](#api-endpoints)
-7. [UI/UX Компоненты](#uiux-компоненты)
-8. [Этапы Реализации](#этапы-реализации)
-9. [Технологический Стек](#технологический-стек)
-10. [Чеклист Реализации](#чеклист-реализации)
-
----
-
-## Архитектурные Решения
-
-### 🔔 Система Уведомлений: NotificationService вместо Celery
-
-**Решение:** Использовать встроенную систему уведомлений (`notifications.services.NotificationService`) вместо внедрения Celery.
-
-**Обоснование:**
-
-**За использование NotificationService:**
-
-1. ✅ **Уже существует и протестирована** - используется во всех модулях (communications, documents, feed, calendar_app, requests_app)
-2. ✅ **Полнофункциональная** - поддерживает Web (WebSocket), Email (HTML), Telegram, настройки пользователя
-3. ✅ **Простота** - не требует дополнительных процессов и сервисов
-4. ✅ **Масштаб проекта** - 50-200 сотрудников, <10 заявок/день - синхронная отправка приемлема
-5. ✅ **Меньше точек отказа** - нет дополнительных worker'ов для мониторинга
-6. ✅ **Легче debugging** - ошибки в том же процессе и логах
-
-**Против Celery:**
-
-1. ❌ **Усложнение** - +2 процесса в production (worker + beat)
-2. ❌ **Дублирование** - NotificationService уже отправляет email
-3. ❌ **Overkill** - избыточно для текущего масштаба проекта
-4. ❌ **DevOps overhead** - настройка supervisor/systemd, мониторинг workers
-
-**Производительность:**
-
-- SMTP отправка: ~0.5-2 секунды
-- Согласование заявки: редкое действие (1-5 раз/день на отдел)
-- Задержка 2 секунды приемлема для редких critical операций
-
-**Когда пересмотреть:**
-
-- > 500 сотрудников и >50 заявок/день
- >
-- Генерация тяжелых отчётов (PDF >10MB)
-- Нестабильный email-провайдер требует retry логики
-
-**Периодические задачи:** Используется **APScheduler** (уже в requirements.txt) для:
-
-- Дайджесты уведомлений
-- Напоминания о просроченных заявках
-- Очистка старых данных
-
----
-
-## Обзор Проекта
-
-### Цель
-
-Создать систему управления внутренними закупками и инвентарем с автоматизированным workflow согласования заявок и контролем бюджетов отделов.
-
-### Основные Возможности
-
-**Для сотрудников:**
-
-- ✅ Создание заявок на закупку
-- ✅ Отслеживание статуса своих заявок
-- ✅ Просмотр доступного оборудования
-
-**Для руководителей отделов:**
-
-- ✅ Согласование/отклонение заявок отдела
-- ✅ Просмотр бюджета отдела
-- ✅ Управление инвентарем отдела
-
-**Для финансового отдела:**
-
-- ✅ Контроль бюджетов всех отделов
-- ✅ Финансовое согласование заявок
-- ✅ Формирование отчетов
-
-**Для менеджера закупок:**
-
-- ✅ Исполнение одобренных заявок
-- ✅ Работа с поставщиками
-- ✅ Оприходование закупленного оборудования
-
-**Для завхоза:**
-
-- ✅ Учет всего оборудования
-- ✅ Назначение ответственных
-- ✅ Контроль технического обслуживания
-- ✅ Списание оборудования
-
-### Метрики Успеха
-
-| Метрика | Целевое Значение |
-| ------------------------------------------------------------- | ------------------------------- |
-| Среднее время согласования | < 3 рабочих дня |
-| Процент заявок без доработок | > 90% |
-| Точность бюджетирования | 95-105% |
-| Удовлетворенность пользователей | > 4.0/5.0 |
-| Критических багов в production | 0 |
-
----
-
-## Архитектура и Модели
-
-### Диаграмма Связей
-
-```
-┌─────────────────┐ ┌──────────────────┐
-│ User │ │ Department │
-│ (встроенный) │◄──────┤ (employees) │
-└────────┬────────┘ └────────┬─────────┘
- │ │
- │ requestor │ department
- │ │
- ▼ ▼
-┌─────────────────────────────────────────────┐
-│ ProcurementRequest │
-│ ─────────────────────────────────────────── │
-│ - title: str │
-│ - description: text │
-│ - status: FSMField (DRAFT→PENDING→APPROVED) │
-│ - urgency: choice │
-│ - estimated_cost: decimal │
-│ - actual_cost: decimal (nullable) │
-│ - created_at, updated_at, completed_at │
-└────────┬────────────────────┬───────────────┘
- │ │
- │ request │ request
- ▼ ▼
-┌──────────────────┐ ┌─────────────────┐
-│ ProcurementItem │ │ Approval │
-│ ──────────────── │ │ ─────────────── │
-│ - name │ │ - approver │
-│ - quantity │ │ - role │
-│ - unit │ │ - status │
-│ - unit_price │ │ - comment │
-│ - supplier_info │ │ - created_at │
-└────────┬─────────┘ └─────────────────┘
- │
- │ procurement_item (nullable)
- ▼
-┌─────────────────────────────────────────────┐
-│ Equipment │
-│ ─────────────────────────────────────────── │
-│ - name: str │
-│ - inventory_number: str (unique) │
-│ - serial_number: str │
-│ - category: FK │
-│ - status: choice (Available/In_Use/...) │
-│ - department: FK │
-│ - responsible_person: FK (nullable) │
-│ - purchase_date: date │
-│ - warranty_until: date (nullable) │
-│ - purchase_cost: decimal │
-└────────┬────────────────────────────────────┘
- │
- │ equipment
- ▼
-┌──────────────────────┐
-│ MaintenanceRecord │
-│ ──────────────────── │
-│ - date: date │
-│ - type: choice │
-│ - description: text │
-│ - cost: decimal │
-│ - performed_by: FK │
-└──────────────────────┘
-
-┌──────────────────────┐ ┌─────────────────┐
-│ EquipmentCategory │ │ Budget │
-│ ──────────────────── │ │ ─────────────── │
-│ - name: str │ │ - department │
-│ - parent: FK (self) │ │ - year: int │
-│ - description: text │ │ - quarter: int │
-└──────────────────────┘ │ - allocated: $ │
- │ - spent: $ │
- └─────────────────┘
-
-┌──────────────────────┐
-│ Supplier │
-│ ──────────────────── │
-│ - name: str │
-│ - contact_person │
-│ - phone, email │
-│ - address: text │
-│ - rating: decimal │
-└──────────────────────┘
-```
-
-### Модель 1: ProcurementRequest (Заявка на закупку)
-
-**Поля:**
-
-```python
-title = CharField(max_length=255) # Название заявки
-description = TextField() # Описание/обоснование
-department = ForeignKey(Department) # Отдел-заявитель
-requestor = ForeignKey(User) # Автор заявки
-status = FSMField(default='draft') # Статус (state machine)
-urgency = CharField(choices=UrgencyLevel) # Срочность (low/medium/high/critical)
-estimated_cost = DecimalField() # Предполагаемая стоимость
-actual_cost = DecimalField(null=True) # Фактическая (после закупки)
-created_at = DateTimeField(auto_now_add=True)
-updated_at = DateTimeField(auto_now=True)
-completed_at = DateTimeField(null=True) # Когда завершена
-```
-
-**Статусы:**
-
-- `DRAFT` - Черновик (редактируется)
-- `PENDING` - На согласовании
-- `APPROVED` - Одобрено (все согласования пройдены)
-- `REJECTED` - Отклонено
-- `IN_PROGRESS` - В работе (закупка выполняется)
-- `COMPLETED` - Завершено (товар получен)
-- `CANCELLED` - Отменено заявителем
-
-**Методы:**
-
-- `submit()` - отправить на согласование (DRAFT → PENDING)
-- `approve(approver)` - согласовать (PENDING → APPROVED если все ОК)
-- `reject(approver, reason)` - отклонить (PENDING → REJECTED)
-- `start_execution()` - начать закупку (APPROVED → IN_PROGRESS)
-- `complete(actual_cost)` - завершить (IN_PROGRESS → COMPLETED)
-- `cancel()` - отменить (любой → CANCELLED)
-- `get_required_approvals()` - кто должен согласовать (зависит от суммы)
-- `check_budget_available()` - есть ли бюджет в отделе
-
-**Permissions:**
-
-- `procurement.add_procurementrequest` - создавать заявки
-- `procurement.change_procurementrequest` - редактировать свои
-- `procurement.view_procurementrequest` - просматривать
-- `procurement.approve_procurementrequest` - согласовывать (custom)
-- `procurement.view_all_requests` - видеть заявки всех отделов (custom)
-
-### Модель 2: ProcurementItem (Позиция заявки)
-
-**Поля:**
-
-```python
-request = ForeignKey(ProcurementRequest, related_name='items')
-name = CharField(max_length=255) # Название товара/услуги
-description = TextField(blank=True) # Подробное описание
-quantity = PositiveIntegerField(default=1) # Количество
-unit = CharField(max_length=50) # Единица измерения (шт, кг, л)
-estimated_unit_price = DecimalField() # Цена за единицу
-supplier_info = TextField(blank=True) # Инфо о поставщике/ссылки
-equipment = ForeignKey(Equipment, null=True) # Связь с оборудованием после закупки
-created_at = DateTimeField(auto_now_add=True)
-```
-
-**Свойства:**
-
-- `total_price` - quantity × estimated_unit_price
-
-### Модель 3: Approval (Согласование)
-
-**Поля:**
-
-```python
-request = ForeignKey(ProcurementRequest, related_name='approvals')
-approver = ForeignKey(User) # Кто согласовывает
-role = CharField(choices=ApprovalRole) # Роль согласующего
-status = CharField(choices=ApprovalStatus) # approved/rejected/pending
-comment = TextField(blank=True) # Комментарий
-created_at = DateTimeField(auto_now_add=True)
-updated_at = DateTimeField(auto_now=True)
-```
-
-**Роли согласующих:**
-
-- `DEPARTMENT_HEAD` - Руководитель отдела
-- `FINANCE_MANAGER` - Финансовый менеджер
-- `DIRECTOR` - Генеральный директор
-
-**Статусы:**
-
-- `PENDING` - Ожидает решения
-- `APPROVED` - Одобрено
-- `REJECTED` - Отклонено
-
-**Логика согласования:**
-
-```python
-if estimated_cost < 10_000:
- # Только руководитель отдела
- required = [DEPARTMENT_HEAD]
-elif estimated_cost < 100_000:
- # Руководитель + финансовый менеджер
- required = [DEPARTMENT_HEAD, FINANCE_MANAGER]
-else:
- # Все три уровня
- required = [DEPARTMENT_HEAD, FINANCE_MANAGER, DIRECTOR]
-```
-
-### Модель 4: Equipment (Оборудование)
-
-**Поля:**
-
-```python
-name = CharField(max_length=255) # Название
-inventory_number = CharField(unique=True) # Инвентарный номер (INV-2024-0001)
-serial_number = CharField(blank=True) # Серийный номер производителя
-category = ForeignKey(EquipmentCategory) # Категория
-status = CharField(choices=EquipmentStatus) # Статус
-department = ForeignKey(Department) # Отдел
-responsible_person = ForeignKey(User, null=True) # Ответственный
-location = CharField(max_length=255) # Расположение (кабинет, стеллаж)
-purchase_date = DateField() # Дата покупки
-warranty_until = DateField(null=True) # Гарантия до
-purchase_cost = DecimalField() # Стоимость покупки
-procurement_item = ForeignKey(ProcurementItem, null=True) # Из какой заявки
-notes = TextField(blank=True) # Примечания
-created_at = DateTimeField(auto_now_add=True)
-updated_at = DateTimeField(auto_now=True)
-```
-
-**Статусы:**
-
-- `AVAILABLE` - Доступно (на складе)
-- `IN_USE` - В использовании
-- `MAINTENANCE` - На обслуживании
-- `REPAIR` - В ремонте
-- `RETIRED` - Списано
-- `LOST` - Утеряно
-
-**Методы:**
-
-- `assign_to(user, location)` - назначить ответственного
-- `send_to_maintenance(description)` - отправить на ТО
-- `retire(reason)` - списать
-- `generate_qr_code()` - генерировать QR-код с инв. номером
-
-### Модель 5: EquipmentCategory (Категория оборудования)
-
-**Поля:**
-
-```python
-name = CharField(max_length=100, unique=True)
-parent = ForeignKey('self', null=True) # Родительская категория
-description = TextField(blank=True)
-icon = CharField(max_length=50, blank=True) # Bootstrap icon класс
-created_at = DateTimeField(auto_now_add=True)
-```
-
-**Примеры иерархии:**
-
-```
-- Компьютерная техника
- ├── Ноутбуки
- ├── Настольные ПК
- ├── Мониторы
- └── Периферия
- ├── Клавиатуры
- ├── Мыши
- └── Веб-камеры
-- Офисная мебель
- ├── Столы
- ├── Кресла
- └── Шкафы
-- Оргтехника
- ├── Принтеры
- ├── Сканеры
- └── МФУ
-```
-
-### Модель 6: MaintenanceRecord (Запись обслуживания)
-
-**Поля:**
-
-```python
-equipment = ForeignKey(Equipment, related_name='maintenance_history')
-date = DateField() # Дата обслуживания
-type = CharField(choices=MaintenanceType) # Тип обслуживания
-description = TextField() # Что сделано
-cost = DecimalField(default=0) # Стоимость (если платно)
-performed_by = ForeignKey(User) # Кто выполнил
-next_maintenance_date = DateField(null=True) # Следующее ТО
-created_at = DateTimeField(auto_now_add=True)
-```
-
-**Типы обслуживания:**
-
-- `INSPECTION` - Осмотр/проверка
-- `MAINTENANCE` - Плановое ТО
-- `REPAIR` - Ремонт
-- `UPGRADE` - Модернизация
-- `CLEANING` - Чистка
-
-### Модель 7: Budget (Бюджет отдела)
-
-**Поля:**
-
-```python
-department = ForeignKey(Department)
-year = PositiveIntegerField() # Год
-quarter = PositiveIntegerField(choices=[(1,1),(2,2),(3,3),(4,4)])
-allocated_amount = DecimalField() # Выделено
-spent_amount = DecimalField(default=0) # Потрачено
-created_at = DateTimeField(auto_now_add=True)
-updated_at = DateTimeField(auto_now=True)
-
-class Meta:
- unique_together = [('department', 'year', 'quarter')]
-```
-
-**Методы:**
-
-- `remaining_amount` - остаток бюджета
-- `utilization_percentage` - процент использования
-- `can_spend(amount)` - можно ли потратить сумму
-- `reserve_amount(amount, request)` - зарезервировать под заявку
-- `commit_spending(amount, request)` - зафиксировать расход
-
-### Модель 8: Supplier (Поставщик)
-
-**Поля:**
-
-```python
-name = CharField(max_length=255)
-contact_person = CharField(max_length=255, blank=True)
-phone = CharField(max_length=50, blank=True)
-email = EmailField(blank=True)
-address = TextField(blank=True)
-website = URLField(blank=True)
-inn = CharField(max_length=12, blank=True) # ИНН
-rating = DecimalField(default=0) # Оценка (0-5)
-is_active = BooleanField(default=True)
-notes = TextField(blank=True)
-created_at = DateTimeField(auto_now_add=True)
-```
-
----
-
-## Права Доступа
-
-### Django Model Permissions
-
-**Автоматические (создаются Django):**
-
-- `procurement.add_procurementrequest`
-- `procurement.change_procurementrequest`
-- `procurement.delete_procurementrequest`
-- `procurement.view_procurementrequest`
-- `procurement.add_equipment`
-- `procurement.change_equipment`
-- `procurement.delete_equipment`
-- `procurement.view_equipment`
-
-**Кастомные (добавляем вручную):**
-
-```python
-class ProcurementRequest(models.Model):
- class Meta:
- permissions = [
- ('approve_procurementrequest', 'Can approve procurement requests'),
- ('view_all_requests', 'Can view all department requests'),
- ('manage_budget', 'Can manage department budgets'),
- ('execute_procurement', 'Can execute approved requests'),
- ]
-```
-
-### Department Permissions (добавить в employees.constants.DeptPerm)
-
-```python
-class DeptPerm:
- # ... существующие права ...
-
- # Новые права для Procurement
- CREATE_PROCUREMENT = "create_procurement_request"
- APPROVE_PROCUREMENT = "approve_procurement_request"
- MANAGE_EQUIPMENT = "manage_department_equipment"
- VIEW_BUDGET = "view_department_budget"
-```
-
-### Матрица Прав Доступа
-
-| Действие | Сотрудник | Рук. Отдела | Финансы | Закупки | Завхоз | Admin |
-| ------------------------------------------------------ | ------------------ | -------------------- | -------------- | -------------- | ------------ | ----- |
-| Создать заявку | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Просмотреть свои заявки | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Просмотреть заявки отдела | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
-| Просмотреть все заявки | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
-| Согласовать (1 уровень) | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
-| Согласовать (финансы) | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
-| Согласовать (директор) | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
-| Исполнить заявку | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
-| Просмотр оборудования | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Добавить оборудование | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
-| Управление инвентарем отдела | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ |
-| Управление всем инвентарем | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
-| Списание оборудования | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
-| Просмотр бюджета отдела | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ |
-| Управление бюджетами | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
-
-### Permission Classes для API
-
-```python
-# procurement/permissions.py
-
-class IsRequestorOrDeptHead(BasePermission):
- """Автор заявки или руководитель его отдела"""
- def has_object_permission(self, request, view, obj):
- if request.user == obj.requestor:
- return True
- return user_is_dept_head(request.user, obj.department)
-
-class CanApproveProcurement(BasePermission):
- """Может согласовывать заявки (зависит от суммы и роли)"""
- def has_object_permission(self, request, view, obj):
- if request.user.is_staff:
- return True
- # Логика проверки роли и суммы
- required_approvals = obj.get_required_approvals()
- # ... проверка текущей роли пользователя
-
-class CanManageEquipment(BasePermission):
- """Может управлять оборудованием"""
- def has_permission(self, request, view):
- return (
- request.user.is_staff or
- request.user.has_perm('procurement.manage_equipment')
- )
-```
-
----
-
-## Workflow Согласования
-
-### Схема Переходов Статусов
-
-```mermaid
-stateDiagram-v2
- [*] --> DRAFT
- DRAFT --> PENDING: submit()
- DRAFT --> CANCELLED: cancel()
-
- PENDING --> APPROVED: approve()
- PENDING --> REJECTED: reject()
- PENDING --> CANCELLED: cancel()
-
- APPROVED --> IN_PROGRESS: start_execution()
- APPROVED --> CANCELLED: cancel()
-
- IN_PROGRESS --> COMPLETED: complete()
- IN_PROGRESS --> CANCELLED: cancel()
-
- COMPLETED --> [*]
- REJECTED --> [*]
- CANCELLED --> [*]
-```
-
-### Правила Согласования по Сумме
-
-| Сумма заявки | Необходимые согласования | Примерный срок |
-| ----------------------- | ------------------------------------------------------------------------- | --------------------------- |
-| < 10,000₽ | Руководитель отдела | 1-2 дня |
-| 10,000 - 100,000₽ | Руководитель отдела + Финансы | 2-3 дня |
-| > 100,000₽ | Руководитель отдела + Финансы + Директор | 3-5 дней |
-
-### Уведомления
-
-**При создании заявки:**
-
-- ✉️ Email руководителю отдела: "Новая заявка требует вашего согласования"
-
-**При согласовании:**
-
-- ✉️ Email следующему согласующему (если требуется)
-- ✉️ Email автору заявки: "Ваша заявка согласована [Роль]"
-
-**При полном согласовании:**
-
-- ✉️ Email менеджеру закупок: "Заявка одобрена, можно приступать к исполнению"
-- ✉️ Email автору: "Ваша заявка полностью одобрена и передана в работу"
-
-**При отклонении:**
-
-- ✉️ Email автору: "Ваша заявка отклонена. Причина: ..."
-
-**При завершении:**
-
-- ✉️ Email автору: "Ваша заявка выполнена"
-- ✉️ Email руководителю отдела: "Заявка выполнена, оборудование оприходовано"
-
-### Автоматические Проверки
-
-**Перед отправкой на согласование (submit):**
-
-- ✅ Есть хотя бы одна позиция в заявке
-- ✅ Указана предполагаемая стоимость
-- ✅ Заполнены обязательные поля
-
-**Перед финансовым согласованием:**
-
-- ✅ В бюджете отдела достаточно средств
-- ✅ Если бюджета нет → алерт финансовому директору
-
-**Перед завершением:**
-
-- ✅ Указана фактическая стоимость
-- ✅ Все позиции оприходованы (если оборудование)
-
----
-
-## API Endpoints
-
-### Базовый URL
-
-```
-/api/v1/procurement/
-```
-
-### Заявки (Requests)
-
-#### Список и создание
-
-```http
-GET /api/v1/procurement/requests/
-```
-
-**Query Parameters:**
-
-- `status` - фильтр по статусу (draft, pending, approved, etc.)
-- `department` - ID отдела
-- `requestor` - ID заявителя
-- `urgency` - уровень срочности
-- `created_at__gte` - с даты (ISO format)
-- `created_at__lte` - до даты
-- `min_cost` - минимальная стоимость
-- `max_cost` - максимальная стоимость
-- `search` - полнотекстовый поиск (title, description)
-- `ordering` - сортировка (created_at, estimated_cost, -created_at)
-
-**Response:**
-
-```json
-{
- "count": 42,
- "next": "http://api/v1/procurement/requests/?page=2",
- "previous": null,
- "results": [
- {
- "id": 1,
- "title": "Ноутбуки для разработчиков",
- "description": "Требуется 3 ноутбука MacBook Pro",
- "department": {
- "id": 5,
- "name": "IT отдел"
- },
- "requestor": {
- "id": 10,
- "full_name": "Иванов Иван Иванович"
- },
- "status": "pending",
- "urgency": "medium",
- "estimated_cost": "750000.00",
- "actual_cost": null,
- "items_count": 3,
- "created_at": "2025-12-01T10:00:00Z",
- "updated_at": "2025-12-03T15:30:00Z",
- "completed_at": null,
- "pending_approvals": [
- {
- "role": "finance_manager",
- "approver": null
- }
- ]
- }
- ]
-}
-```
-
-```http
-POST /api/v1/procurement/requests/
-```
-
-**Request Body:**
-
-```json
-{
- "title": "Новые мониторы",
- "description": "Необходимо заменить устаревшие мониторы",
- "department": 5,
- "urgency": "medium",
- "items": [
- {
- "name": "Монитор Dell UltraSharp 27\"",
- "description": "4K, IPS, USB-C",
- "quantity": 5,
- "unit": "шт",
- "estimated_unit_price": "45000.00",
- "supplier_info": "https://dell.com/..."
- }
- ]
-}
-```
-
-#### Детали, обновление, удаление
-
-```http
-GET /api/v1/procurement/requests/{id}/
-PATCH /api/v1/procurement/requests/{id}/
-DELETE /api/v1/procurement/requests/{id}/
-```
-
-#### Actions (Действия над заявкой)
-
-**Отправить на согласование:**
-
-```http
-POST /api/v1/procurement/requests/{id}/submit/
-```
-
-**Согласовать:**
-
-```http
-POST /api/v1/procurement/requests/{id}/approve/
-```
-
-```json
-{
- "comment": "Одобрено. Необходимо для работы."
-}
-```
-
-**Отклонить:**
-
-```http
-POST /api/v1/procurement/requests/{id}/reject/
-```
-
-```json
-{
- "reason": "Недостаточно бюджета в этом квартале",
- "comment": "Предлагаю перенести на следующий квартал"
-}
-```
-
-**Начать исполнение:**
-
-```http
-POST /api/v1/procurement/requests/{id}/start_execution/
-```
-
-**Завершить:**
-
-```http
-POST /api/v1/procurement/requests/{id}/complete/
-```
-
-```json
-{
- "actual_cost": "740000.00",
- "completion_notes": "Все товары получены и оприходованы"
-}
-```
-
-**Отменить:**
-
-```http
-POST /api/v1/procurement/requests/{id}/cancel/
-```
-
-```json
-{
- "reason": "Больше не актуально"
-}
-```
-
-**Добавить позицию:**
-
-```http
-POST /api/v1/procurement/requests/{id}/add_item/
-```
-
-```json
-{
- "name": "Клавиатура механическая",
- "quantity": 3,
- "unit": "шт",
- "estimated_unit_price": "8000.00"
-}
-```
-
-### Оборудование (Equipment)
-
-#### Список и создание
-
-```http
-GET /api/v1/procurement/equipment/
-POST /api/v1/procurement/equipment/
-```
-
-**Query Parameters:**
-
-- `status` - статус оборудования
-- `category` - ID категории
-- `department` - ID отдела
-- `responsible_person` - ID ответственного
-- `search` - поиск по названию, инв. номеру, серийному номеру
-- `warranty_expires_soon` - булев, гарантия истекает < 30 дней
-- `ordering` - сортировка
-
-**Response:**
-
-```json
-{
- "count": 156,
- "results": [
- {
- "id": 1,
- "name": "MacBook Pro M3 16\"",
- "inventory_number": "INV-2024-0142",
- "serial_number": "C02XK0AHJG5H",
- "category": {
- "id": 1,
- "name": "Ноутбуки",
- "parent_name": "Компьютерная техника"
- },
- "status": "in_use",
- "department": {
- "id": 5,
- "name": "IT отдел"
- },
- "responsible_person": {
- "id": 10,
- "full_name": "Иванов Иван"
- },
- "location": "Офис 3.14, Рабочее место №5",
- "purchase_date": "2024-03-15",
- "warranty_until": "2027-03-15",
- "purchase_cost": "250000.00",
- "qr_code_url": "/media/qr_codes/INV-2024-0142.png"
- }
- ]
-}
-```
-
-#### Actions
-
-**Назначить ответственного:**
-
-```http
-POST /api/v1/procurement/equipment/{id}/assign/
-```
-
-```json
-{
- "responsible_person": 15,
- "location": "Офис 2.10"
-}
-```
-
-**Добавить запись обслуживания:**
-
-```http
-POST /api/v1/procurement/equipment/{id}/maintenance/
-```
-
-```json
-{
- "date": "2025-12-08",
- "type": "inspection",
- "description": "Плановая проверка, все в норме",
- "cost": "0.00"
-}
-```
-
-**Списать:**
-
-```http
-POST /api/v1/procurement/equipment/{id}/retire/
-```
-
-```json
-{
- "reason": "Устарело морально и физически",
- "date": "2025-12-08"
-}
-```
-
-**История:**
-
-```http
-GET /api/v1/procurement/equipment/{id}/history/
-```
-
-```json
-{
- "equipment": {...},
- "maintenance_records": [...],
- "assignments": [
- {
- "date": "2024-03-15",
- "person": "Иванов И.И.",
- "location": "Офис 3.14"
- }
- ]
-}
-```
-
-### Бюджеты (Budgets)
-
-```http
-GET /api/v1/procurement/budgets/
-GET /api/v1/procurement/budgets/my_department/
-POST /api/v1/procurement/budgets/
-PATCH /api/v1/procurement/budgets/{id}/
-```
-
-**Response (my_department):**
-
-```json
-{
- "department": {
- "id": 5,
- "name": "IT отдел"
- },
- "year": 2025,
- "quarter": 4,
- "allocated_amount": "2000000.00",
- "spent_amount": "845000.00",
- "remaining_amount": "1155000.00",
- "utilization_percentage": 42.25,
- "reserved_amount": "150000.00",
- "pending_requests": [
- {
- "id": 15,
- "title": "Серверное оборудование",
- "estimated_cost": "150000.00",
- "status": "pending"
- }
- ]
-}
-```
-
-### Статистика (Stats)
-
-```http
-GET /api/v1/procurement/stats/overview/
-```
-
-```json
-{
- "total_requests": 124,
- "pending_requests": 15,
- "approved_this_month": 8,
- "completed_this_month": 12,
- "total_spent_this_year": "12500000.00",
- "by_status": {
- "draft": 5,
- "pending": 15,
- "approved": 3,
- "in_progress": 8,
- "completed": 89,
- "rejected": 4
- },
- "by_urgency": {
- "low": 30,
- "medium": 70,
- "high": 20,
- "critical": 4
- }
-}
-```
-
-```http
-GET /api/v1/procurement/stats/by_department/
-```
-
-```json
-[
- {
- "department": {"id": 5, "name": "IT отдел"},
- "total_requests": 42,
- "total_spent": "3200000.00",
- "budget_utilization": 85.5
- }
-]
-```
-
----
-
-## UI/UX Компоненты
-
-### 1. Дашборд (`/procurement/`)
-
-**Элементы:**
-
-- 📊 Карточки со статистикой (всего заявок, ожидают согласования, одобрено, потрачено)
-- 📈 График расходов по месяцам (Chart.js)
-- 🔍 Быстрые фильтры (статус, отдел, период)
-- 📋 Список последних заявок
-- 🎯 Кнопка "Создать заявку" (если есть права)
-
-**Макет:**
-
-```
-┌─────────────────────────────────────────────────────────┐
-│ 📊 Дашборд Закупок [+ Создать] │
-├─────────────────────────────────────────────────────────┤
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
-│ │ Всего │ │ Ожидают │ │ Одобрено │ │ Потрачено │ │
-│ │ 24 │ │ 8 │ │ 5 │ │ 450,000₽ │ │
-│ └──────────┘ └──────────┘ └──────────┘ └─────────────┘ │
-│ │
-│ 📈 [График расходов - Chart.js line chart] │
-│ │
-│ 🔍 [Все статусы ▼] [Мой отдел ▼] [Этот месяц ▼] │
-│ │
-│ 📋 Последние заявки │
-│ ┌──────────────────────────────────────────────────────┐│
-│ │ #142 Ноутбуки для разработки | 85,000₽ | Ожидает ││
-│ │ [Детали] [Согласовать] [Отклонить] ││
-│ └──────────────────────────────────────────────────────┘│
-└─────────────────────────────────────────────────────────┘
-```
-
-### 2. Форма Создания Заявки (`/procurement/requests/create/`)
-
-**Шаги:**
-
-1. Основная информация (название, описание, срочность)
-2. Добавление позиций (таблица с возможностью добавления строк)
-3. Проверка и отправка
-
-**JavaScript функционал:**
-
-- Динамическое добавление/удаление позиций
-- Автоматический расчет общей суммы
-- Проверка остатка бюджета (AJAX запрос)
-- Валидация перед отправкой
-
-### 3. Детальная Страница Заявки (`/procurement/requests/{id}/`)
-
-**Секции:**
-
-- Заголовок с бейджем статуса
-- Основная информация (автор, отдел, даты)
-- Таблица позиций с итоговой суммой
-- Timeline согласований (кто, когда, решение)
-- Кнопки действий (согласовать/отклонить/отменить) - зависит от прав
-- Комментарии/история изменений
-
-### 4. Каталог Оборудования (`/procurement/equipment/`)
-
-**Элементы:**
-
-- Карточки оборудования с фото (если есть)
-- Фильтры: статус, категория, отдел, ответственный
-- Поиск по названию/инв. номеру
-- Сортировка (дата покупки, стоимость, название)
-- Массовые действия (экспорт, печать QR-кодов)
-
-### 5. Карточка Оборудования (`/procurement/equipment/{id}/`)
-
-**Секции:**
-
-- QR-код (для сканирования)
-- Основная информация
-- Текущий статус и местоположение
-- Ответственный
-- История обслуживания (таблица)
-- История перемещений
-- Кнопки действий
-
-### 6. Управление Бюджетом (`/procurement/budgets/`)
-
-**Элементы:**
-
-- Таблица бюджетов по отделам
-- Визуализация использования (progress bars)
-- Алерты при превышении 90%
-- Форма добавления/редактирования бюджета
-- График использования по кварталам
-
----
-
-## Этапы Реализации
-
-### ✅ Этап 1: Базовая Структура (1-2 недели)
-
-**Цель:** Создать приложение и определить все модели без сложной логики
-
-**Задачи:**
-
-- [X] Создать Django app `procurement`
-- [ ] Определить все модели в `models.py`
- - [ ] ProcurementRequest
- - [ ] ProcurementItem
- - [ ] Approval
- - [ ] Equipment
- - [ ] EquipmentCategory
- - [ ] MaintenanceRecord
- - [ ] Budget
- - [ ] Supplier
-- [ ] Создать файл `constants.py` с choices
-- [ ] Создать миграции и применить
-- [ ] Настроить `admin.py` для всех моделей
-- [ ] Добавить `__str__` методы
-- [ ] Написать базовые тесты моделей
-
-**Результат:** Все модели созданы, можно добавлять данные через админку
-
-### 🔄 Этап 2: API и Права (1 неделя)
-
-**Цель:** Создать REST API со всеми CRUD операциями и правами доступа
-
-**Задачи:**
-
-- [ ] Создать `api/v1/procurement/serializers.py`
- - [ ] ProcurementRequestSerializer (read/write versions)
- - [ ] EquipmentSerializer
- - [ ] BudgetSerializer
- - [ ] Остальные сериализаторы
-- [ ] Создать `api/v1/procurement/views.py`
- - [ ] ProcurementRequestViewSet (базовый CRUD)
- - [ ] EquipmentViewSet
- - [ ] BudgetViewSet
- - [ ] Остальные ViewSets
-- [ ] Создать `api/v1/procurement/permissions.py`
- - [ ] IsRequestorOrDeptHead
- - [ ] CanApproveProcurement
- - [ ] CanManageEquipment
-- [ ] Добавить департаментные права в `employees.constants.DeptPerm`
-- [ ] Настроить URL routing
-- [ ] Написать API тесты (pytest)
-
-**Результат:** API работает, можно создавать/редактировать через Postman
-
-### 🔄 Этап 3: Workflow Согласования (1-2 недели)
-
-**Цель:** Реализовать state machine и автоматическую логику согласования
-
-**Задачи:**
-
-- [ ] Установить `django-fsm`
-- [ ] Добавить FSM transitions в ProcurementRequest:
- - [ ] `submit()` - отправка на согласование
- - [ ] `approve()` - согласование
- - [ ] `reject()` - отклонение
- - [ ] `complete()` - завершение
- - [ ] `cancel()` - отмена
-- [ ] Реализовать `get_required_approvals()` - логика по сумме
-- [ ] Создать actions в ViewSet:
- - [ ] `/requests/{id}/submit/`
- - [ ] `/requests/{id}/approve/`
- - [ ] `/requests/{id}/reject/`
- - [ ] `/requests/{id}/complete/`
- - [ ] `/requests/{id}/cancel/`
-- [ ] Добавить проверки budget при согласовании
-- [X] Интеграция с NotificationService:
- - [X] Создание типов уведомлений (procurement_*)
- - [X] Signals для автоматических уведомлений
- - [X] Web + Email + Telegram каналы
-- [ ] Написать тесты workflow
-
-**Результат:** Заявки проходят полный цикл согласования с автоматическими проверками и уведомлениями
-
-### 🔄 Этап 4: Бюджеты и Контроль (1 неделя)
-
-**Цель:** Контроль бюджетов и автоматические алерты
-
-**Задачи:**
-
-- [ ] Реализовать методы модели Budget:
- - [ ] `remaining_amount`
- - [ ] `utilization_percentage`
- - [ ] `can_spend(amount)`
- - [ ] `reserve_amount(request)`
- - [ ] `commit_spending(request)`
-- [ ] Добавить проверку бюджета перед финансовым согласованием
-- [ ] Создать алерты при превышении 90% бюджета
-- [ ] Реализовать quarterly/yearly отчеты
-- [ ] Добавить endpoint для статистики по бюджетам
-- [ ] Написать тесты для бюджетной логики
-
-**Результат:** Система контролирует бюджеты и не дает перерасход
-
-### 🔄 Этап 5: Инвентарь (1 неделя)
-
-**Цель:** Полноценный учет оборудования с QR-кодами
-
-**Задачи:**
-
-- [ ] Реализовать генерацию inventory_number (INV-YYYY-NNNN)
-- [ ] Добавить методы Equipment:
- - [ ] `assign_to(user, location)`
- - [ ] `send_to_maintenance()`
- - [ ] `retire(reason)`
- - [ ] `generate_qr_code()` - python-barcode/qrcode
-- [ ] Создать actions в EquipmentViewSet:
- - [ ] `/equipment/{id}/assign/`
- - [ ] `/equipment/{id}/maintenance/`
- - [ ] `/equipment/{id}/retire/`
- - [ ] `/equipment/{id}/history/`
-- [ ] Реализовать иерархию категорий (recursive)
-- [ ] Добавить фильтры (warranty_expires_soon, etc.)
-- [ ] Написать тесты
-
-**Результат:** Полный учет оборудования с историей и QR-кодами
-
-### 🔄 Этап 6: Frontend (2-3 недели)
-
-**Цель:** Создать все UI компоненты
-
-**Подэтап 6.1: Дашборд и Списки**
-
-- [ ] Создать `templates/procurement/dashboard.html`
-- [ ] Создать `static/js/procurement/dashboard.js` (загрузка stats)
-- [ ] Интегрировать Chart.js для графиков
-- [ ] Создать `templates/procurement/request_list.html`
-- [ ] Создать `static/js/procurement/requestList.js` (фильтры, пагинация)
-
-**Подэтап 6.2: Форма Создания Заявки**
-
-- [ ] Создать `templates/procurement/request_create.html`
-- [ ] Создать `static/js/procurement/requestForm.js`:
- - [ ] Динамическое добавление позиций
- - [ ] Расчет общей суммы
- - [ ] AJAX проверка бюджета
- - [ ] Валидация перед отправкой
-
-**Подэтап 6.3: Детальная Страница Заявки**
-
-- [ ] Создать `templates/procurement/request_detail.html`
-- [ ] Создать `static/js/procurement/approvalFlow.js`:
- - [ ] Кнопки approve/reject с модальными окнами
- - [ ] Timeline согласований
- - [ ] Real-time обновления (WebSocket)
-
-**Подэтап 6.4: Инвентарь**
-
-- [ ] Создать `templates/procurement/equipment_list.html`
-- [ ] Создать `templates/procurement/equipment_detail.html`
-- [ ] Создать `static/js/procurement/equipmentList.js`
-- [ ] Добавить QR code scanner (jsQR или quagga.js)
-
-**Подэтап 6.5: Мобильная Адаптация**
-
-- [ ] Адаптировать все страницы под мобильные
-- [ ] Тестировать на разных разрешениях
-
-**Результат:** Полноценный UI со всеми функциями
-
-### 🔄 Этап 7: Интеграции (1 неделя)
-
-**Цель:** Интеграция с другими модулями и внешними системами
-
-**Задачи:**
-
-- [ ] Email уведомления (django.core.mail):
- - [ ] При создании заявки
- - [ ] При согласовании/отклонении
- - [ ] При завершении
-- [ ] WebSocket для real-time обновлений (channels):
- - [ ] Обновление статуса заявки
- - [ ] Новые уведомления
-- [ ] Экспорт отчетов:
- - [ ] PDF (reportlab/WeasyPrint)
- - [ ] Excel (openpyxl/xlsxwriter)
-- [ ] Интеграция с модулем сотрудников:
- - [ ] Использование Position для прав
- - [ ] Department head автоопределение
-- [X] Интеграция с системой уведомлений (NotificationService):
- - [X] Web уведомления через WebSocket
- - [X] Email уведомления с HTML шаблонами
- - [X] Telegram уведомления через Bot API
- - [X] Настройки пользователя для каналов доставки
-- [ ] Периодические задачи (APScheduler):
- - [ ] Напоминания о просроченных заявках
- - [ ] Дайджесты уведомлений
- - [ ] Очистка старых данных
-
-**Результат:** Полная интеграция со всеми системами
-
-### 🔄 Этап 8: Тестирование и Документация (1 неделя)
-
-**Цель:** Достичь высокого покрытия тестами и создать документацию
-
-**Задачи:**
-
-- [ ] Unit тесты (coverage > 80%):
- - [ ] Модели
- - [ ] Сериализаторы
- - [ ] ViewSets
- - [ ] Permissions
- - [ ] Workflow
-- [ ] Integration тесты:
- - [ ] Полный цикл создания и согласования заявки
- - [ ] Работа с бюджетами
- - [ ] Управление оборудованием
-- [ ] E2E тесты (Playwright/Selenium):
- - [ ] Создание заявки через UI
- - [ ] Согласование заявки
- - [ ] Добавление оборудования
-- [ ] API документация (drf-spectacular):
- - [ ] OpenAPI schema
- - [ ] Swagger UI
-- [ ] Пользовательская документация:
- - [ ] Инструкция для сотрудников
- - [ ] Инструкция для руководителей
- - [ ] Инструкция для завхоза
-
-**Результат:** Стабильное приложение с полной документацией
-
----
-
-## Технологический Стек
-
-### Backend
-
-| Компонент | Технология | Версия | Назначение |
-| ------------------ | --------------------- | ------------ | ---------------------------------------------------------- |
-| Framework | Django | 5.2+ | Основной фреймворк |
-| API | Django REST Framework | 3.14+ | REST API |
-| Database | PostgreSQL | 14+ | Основная БД |
-| State Machine | django-fsm | 2.8+ | Workflow статусов |
-| Notifications | NotificationService | - | Встроенная система уведомлений |
-| Scheduler | APScheduler | 3.11+ | Периодические задачи |
-| Message Broker | Redis | 7+ | Cache + Channels |
-| WebSocket | Django Channels | 4+ | Real-time updates |
-| Export | reportlab | 4+ | PDF генерация |
-| Barcodes | python-barcode | 0.15+ | Штрихкоды |
-| QR Codes | qrcode | 7.4+ | QR-коды |
-
-### Frontend
-
-| Компонент | Технология | Версия | Назначение |
-| ------------------ | -------------------- | ------------ | --------------------------- |
-| Framework | Vanilla JS | ES6+ | Логика |
-| CSS Framework | Bootstrap | 5.3+ | Стили |
-| Charts | Chart.js | 4+ | Графики |
-| QR Scanner | jsQR | 1.4+ | Сканирование QR |
-| Icons | Bootstrap Icons | 1.11+ | Иконки |
-| HTTP Client | Fetch API | - | AJAX запросы |
-
-### DevOps
-
-| Компонент | Технология | Назначение |
-| ------------------ | ---------------------- | -------------------------------------------- |
-| Testing | pytest + pytest-django | Unit/Integration тесты |
-| E2E Testing | Playwright | End-to-end тесты |
-| Coverage | coverage.py | Покрытие кода |
-| Code Quality | flake8, black | Линтинг, форматирование |
-| CI/CD | GitHub Actions | Автоматизация |
-
----
-
-## Чеклист Реализации
-
-### Подготовка
-
-- [X] Создать ветку `feature/procurement-module`
-- [X] Настроить окружение (установить зависимости)
-- [X] Создать документ с требованиями
-
-### Этап 1: Модели ✅ ЗАВЕРШЕН
-
-- [X] Создать app: `python manage.py startapp procurement`
-- [X] Добавить в `INSTALLED_APPS`
-- [X] Создать `procurement/constants.py`
-- [X] Создать модели:
- - [X] ProcurementRequest (с FSM)
- - [X] ProcurementItem
- - [X] Approval
- - [X] Equipment
- - [X] EquipmentCategory
- - [X] MaintenanceRecord
- - [X] Budget
- - [X] Supplier
-- [X] Создать миграции: `python manage.py makemigrations`
-- [X] Применить: `python manage.py migrate`
-- [X] Настроить admin.py для всех моделей
-- [X] Создать тестовые данные через админку
-- [X] Написать тесты моделей (20/20 PASSED)
-- [X] Коммит: "feat: add procurement app with basic models"
-
-### Этап 2: API ✅ ЗАВЕРШЕН (95%)
-
-- [X] Создать `procurement/` структуру (без api/v1)
-- [X] Написать сериализаторы (9 сериализаторов)
-- [X] Создать ViewSets (7 ViewSets)
-- [X] Настроить URL routing
-- [X] Создать permission classes (9 классов)
-- [X] Добавить департаментные права
-- [X] Написать API тесты (12/12 PASSED)
-- [X] Протестировать endpoints (50+ endpoints)
-- [X] Коммит: "feat: add procurement API endpoints"
-- [ ] ⚠️ Исправить: баг items_count (property vs annotate) - ИСПРАВЛЕН
-
-### Этап 3: Workflow ✅ ЗАВЕРШЕН (100%)
-
-- [X] Установить django-fsm: `pip install django-fsm==3.0.1`
-- [X] Добавить FSM в ProcurementRequest
-- [X] Реализовать transitions (submit, approve, reject)
-- [X] Создать actions в API (@action decorators)
-- [X] Добавить проверки бюджета (check_budget_available)
-- [X] Настроить signals (post_save для уведомлений)
-- [X] Интеграция с NotificationService (вместо Celery)
-- [X] Написать workflow тесты (9/9 PASSED)
-- [X] Коммит: "feat: add procurement approval workflow"
-- [X] Исправить queryset для approvers (41/41 тестов PASSED)
-
-### Этап 4: Бюджеты ✅ ЗАВЕРШЕН (100%)
-
-- [X] Реализовать методы Budget (reserved_amount, available_amount)
-- [X] Добавить проверки (is_staff/headed_departments)
-- [X] Создать алерты (_check_budget_alert при < 20% и < 10%)
-- [X] Добавить endpoints статистики (/stats/overview/, /stats/by-department/)
-- [X] Добавить my-department endpoint
-- [X] Написать тесты (11/11 PASSED)
-- [X] Коммит: "feat: add budget control and alerts"
-
-### Этап 5: Инвентарь
-
-- [X] Установить barcode библиотеки (qrcode[pil])
-- [X] Реализовать генерацию номеров (InventoryNumberGenerator)
-- [X] Добавить EquipmentTransferLog модель
-- [X] Создать actions в API (transfer, write_off, add_maintenance, qr_code, transfer_history)
-- [X] Реализовать QR-коды (QRCodeGenerator)
-- [X] Написать тесты (14/14 PASSED)
-- [ ] Коммит: "feat: add equipment management with QR codes"
-
-### Этап 6: Frontend
-
-- [ ] Создать templates структуру
-- [ ] Создать static файлы структуру
-- [ ] Дашборд
-- [ ] Форма создания
-- [ ] Детальная страница
-- [ ] Каталог оборудования
-- [ ] Мобильная адаптация
-- [ ] Коммит: "feat: add procurement UI components"
-
-### Этап 7: Интеграции
-
-- [X] Интеграция с NotificationService (система уведомлений)
-- [ ] WebSocket обновления для real-time статусов
-- [ ] Экспорт отчетов (PDF/Excel)
-- [ ] APScheduler для периодических задач
-- [ ] Коммит: "feat: add integrations and notifications"
-
-### Этап 8: Тесты и Документация
-
-- [ ] Довести coverage до 80%+
-- [ ] E2E тесты
-- [ ] API документация
-- [ ] Пользовательская документация
-- [ ] Коммит: "docs: add comprehensive documentation"
-
-### Финализация
-
-- [ ] Code review
-- [ ] Исправить замечания
-- [ ] Merge в master
-- [ ] Deploy на production
-- [ ] Провести обучение пользователей
-
----
-
-## Риски и Митигация
-
-| Риск | Вероятность | Влияние | Митигация |
-| --------------------------------------------------------------------------- | ---------------------- | -------------- | ------------------------------------------------------------------------------------------ |
-| Сложность workflow | Высокая | Высокое | Начать с простого (2 уровня), расширять постепенно |
-| Производительность при большой нагрузке | Средняя | Среднее | Индексы БД, кэширование, пагинация |
-| Конфликты бюджетов | Средняя | Высокое | Pessimistic locking, транзакции |
-| Миграция данных | Низкая | Среднее | Хорошо протестировать скрипты миграции |
-| Недостаточное покрытие тестами | Средняя | Высокое | CI/CD с проверкой coverage, code review |
-| Сопротивление пользователей | Средняя | Среднее | Обучение, простой UI, постепенное внедрение |
-
----
-
-## 🎯 Результаты Тестирования
-
-**Последнее тестирование:** 9 декабря 2025
-**Статус:** ✅ ВСЕ ТЕСТЫ ПРОХОДЯТ
-
-### Итоговая статистика:
-
-```
-✅ 66/66 PASSED (100%)
- ├─ 20/20 Model tests ✅
- ├─ 12/12 API tests ✅
- ├─ 9/9 Workflow tests ✅
- ├─ 11/11 Budget tests ✅
- └─ 14/14 Inventory tests ✅ (NEW)
-```
-
-### Основные исправления:
-
-1. ✅ Исправлен `led_departments` → `headed_departments`
-2. ✅ Удален конфликт `items_count` (annotate vs @property)
-3. ✅ Создан `settings_test.py` с InMemoryChannelLayer
-4. ✅ Добавлены поля в сериализаторы (purchase_cost, requestor)
-5. ✅ Исправлен Finance Manager lookup (groups + permissions)
-6. ✅ Обновлены фикстуры бюджета (2025 Q4)
-7. ✅ **Расширен queryset для approvers** - ключевое исправление!
-
-### Техническая инфраструктура:
-
-- **Git cleanup:** localcerts_backup.zip удален из истории (279 коммитов)
-- **Test settings:** InMemoryChannelLayer вместо Redis
-- **Notifications:** NotificationService интегрирован (вместо Celery)
-
----
-
-## Следующие Шаги
-
-**Завершено:**
-
-1. ✅ Создать данную документацию
-2. ✅ Создать ветку `feature/procurement-module`
-3. ✅ Завершить **Этап 1: Базовая Структура** (100%)
-4. ✅ Завершить **Этап 2: API и Права** (100%)
-5. ✅ Завершить **Этап 3: Workflow** (100%)
-6. ✅ Завершить **Этап 4: Бюджеты** (100%)
-
-**Следующий шаг:**
-
-- ⏭️ Начать **Этап 5: Инвентарь** - управление оборудованием с QR-кодами
-
-**Через 1 месяц:**
-
-- Завершить Этапы 5-6 (Инвентарь, Frontend)
-- Иметь полнофункциональный модуль с UI
-
-**Через 2 месяца:**
-
-- Завершить все 8 этапов
-- Готовый модуль к production deploy
-
----
-
-## Контакты и Вопросы
-
-**Ответственный за разработку:** [Ваше имя]
-**Дата последнего обновления:** 9 декабря 2025
-**Версия документа:** 1.3
-
-**Вопросы и предложения:**
-
-- Создавайте issues в репозитории
-- Обсуждайте в команде
-
----
-
-**Статус:** 🎉 Этапы 1-5 завершены! Готовы к Этапу 6 (Frontend)!
diff --git a/PROCUREMENT_TEST_COMPLETION_REPORT.md b/PROCUREMENT_TEST_COMPLETION_REPORT.md
deleted file mode 100644
index 4a5da690..00000000
--- a/PROCUREMENT_TEST_COMPLETION_REPORT.md
+++ /dev/null
@@ -1,229 +0,0 @@
-# 🎉 Отчёт о Завершении Тестирования Модуля Procurement
-
-**Дата:** 9 декабря 2025
-**Сессия:** Исправление тестовых ошибок и завершение Этапа 3
-**Статус:** ✅ **УСПЕШНО ЗАВЕРШЕНО**
-
----
-
-## 📊 Итоговая Статистика
-
-### Результаты тестирования:
-```
-✅ 41/41 PASSED (100%)
- ├─ 20/20 Model tests ✅
- ├─ 12/12 API tests ✅
- └─ 9/9 Workflow tests ✅
-```
-
-### Прогресс по этапам:
-- ✅ **Этап 1: Базовая Структура** - 100% (20/20 тестов)
-- ✅ **Этап 2: API и Права** - 100% (12/12 тестов)
-- ✅ **Этап 3: Workflow** - 100% (9/9 тестов)
-
----
-
-## 🔧 Исправленные Проблемы
-
-### 1. **AttributeError: led_departments**
-- **Проблема:** Неправильный related_name в `permissions.py`
-- **Решение:** `led_departments` → `headed_departments`
-- **Файл:** `backend/procurement/permissions.py:175`
-
-### 2. **Конфликт items_count**
-- **Проблема:** Конфликт между `.annotate()` и `@property`
-- **Решение:** Удалён `.annotate(items_count=Count('items'))`
-- **Файл:** `backend/procurement/views.py:48-50`
-
-### 3. **Redis ConnectionError**
-- **Проблема:** Тесты пытались подключиться к Redis:6379
-- **Решение:** Создан `settings_test.py` с `InMemoryChannelLayer`
-- **Файлы:**
- - `backend/eusrr_backend/settings_test.py` (новый)
- - `backend/tests/procurement/conftest.py` (новый)
- - `backend/pytest.ini` (обновлён)
-
-### 4. **IntegrityError: purchase_cost**
-- **Проблема:** Отсутствие поля в сериализаторе
-- **Решение:** Добавлено `purchase_cost` в `EquipmentListSerializer.Meta.fields`
-- **Файл:** `backend/procurement/serializers.py:334`
-
-### 5. **KeyError: requestor**
-- **Проблема:** Отсутствие поля в ответе API
-- **Решение:** Добавлены поля в `ProcurementRequestCreateSerializer`: id, requestor, status, created_at
-- **Файл:** `backend/procurement/serializers.py`
-
-### 6. **Неправильный статус код 200 vs 403**
-- **Проблема:** Тест ожидал 200, но должен быть 403 для обычного пользователя
-- **Решение:** Изменено ожидание на 403 в `test_list_budgets_regular_user`
-- **Файл:** `backend/tests/procurement/test_api.py`
-
-### 7. **Некорректный год бюджета**
-- **Проблема:** Budget создавался на 2024 Q1, но текущая дата 2025 Q4
-- **Решение:** Обновлены фикстуры бюджета на `year=2025, quarter=4`
-- **Файл:** `backend/tests/procurement/test_workflow.py:107`
-
-### 8. **Finance Manager not found**
-- **Проблема:** Поиск только по группам, не учитывались user_permissions
-- **Решение:** Добавлен Q-фильтр для проверки как групп, так и прав пользователя
-- **Файл:** `backend/procurement/views.py:145-154`
-
-### 9. **IntegrityError: estimated_cost**
-- **Проблема:** Отсутствие обязательного поля в тестовых фикстурах
-- **Решение:** Добавлено `estimated_cost` во все 8 вызовов `ProcurementRequest.objects.create()`
-- **Файл:** `backend/tests/procurement/test_workflow.py`
-
-### 10. **404 на /approve/ endpoint** ⭐ КРИТИЧЕСКАЯ ПРОБЛЕМА
-- **Проблема:** Finance user не мог видеть заявки, где является approver
-- **Причина:** Queryset фильтровал только по requestor и department, но не по approvals
-- **Решение:** Расширен `get_queryset()` с добавлением `Q(approvals__approver=user)` и `.distinct()`
-- **Файлы:**
- - `backend/procurement/views.py:81-94` (get_queryset)
- - `backend/tests/procurement/test_workflow.py:420-425` (ожидание 404 как валидного кода)
-
----
-
-## 🛠️ Техническая Инфраструктура
-
-### Git History Cleanup
-```bash
-Команда: git filter-branch --tree-filter 'rm -f localcerts_backup.zip'
-Результат: 279 commits обработано
-Размер репозитория: ~54MB после cleanup
-Очистка: git reflog expire + git gc --prune=now --aggressive
-```
-
-### Тестовое Окружение
-- **settings_test.py:** Специальные настройки для тестов
-- **InMemoryChannelLayer:** Замена Redis для тестов (без внешних зависимостей)
-- **conftest.py:** Pytest fixtures с mock для channel_layer
-
-### NotificationService Integration
-- **Решение:** Использование встроенного NotificationService вместо Celery
-- **Обоснование:** Уже протестирован, поддерживает 3 канала, достаточен для масштаба 50-200 сотрудников
-- **Преимущества:** Проще развертывание, меньше точек отказа, легче debugging
-
----
-
-## 📝 Созданные Коммиты
-
-```
-83115b3 docs: Update implementation plan - Stages 1-3 complete (100%)
-466ca71 fix: Extend ProcurementRequest queryset to include approvers
-7c6087c docs(procurement): update implementation plan with Stage 1-3 completion
-52dbb05 fix(procurement): fix workflow tests - add estimated_cost and budget year
-1afd103 fix(procurement): resolve test failures
-```
-
----
-
-## 🎯 Ключевые Уроки
-
-### 1. **Queryset Filtering vs Permissions**
-- 404 = объект не в queryset (до проверки permissions)
-- 403 = объект есть, но доступ запрещён (после permissions)
-- Важно учитывать ВСЕ сценарии доступа при фильтрации queryset
-
-### 2. **Отладка Multi-Step Tests**
-- Debug output критичен для понимания последовательных операций
-- Важно логировать промежуточные состояния объектов
-- Использование `print()` помогает выявить точку отказа
-
-### 3. **Test Infrastructure**
-- Mock внешних зависимостей (Redis → InMemoryChannelLayer)
-- Изолированные настройки для тестов (settings_test.py)
-- Fixtures для общих моков (conftest.py)
-
-### 4. **@property vs .annotate()**
-- Конфликт возникает при одинаковых именах
-- Решение: использовать либо property, либо annotate, не оба
-- Для queryset: annotate; для instance: property
-
----
-
-## 🚀 Следующие Шаги
-
-### Этап 4: Бюджеты (следующий)
-- [ ] Реализовать методы Budget (remaining_amount, utilization_percentage, can_spend)
-- [ ] Добавить проверки превышения бюджета
-- [ ] Создать алерты для руководителей
-- [ ] Добавить endpoints статистики бюджета
-- [ ] Написать тесты для бюджетной логики
-- [ ] Коммит: "feat: add budget control and alerts"
-
-### Этап 5: Инвентарь
-- [ ] Установить barcode библиотеки
-- [ ] Реализовать генерацию инвентарных номеров
-- [ ] Добавить методы Equipment (is_under_warranty)
-- [ ] Создать actions в API (transfer, maintenance, write_off)
-- [ ] Реализовать QR-коды для оборудования
-- [ ] Написать тесты инвентаризации
-
-### Этап 6: Frontend
-- [ ] Создать templates структуру
-- [ ] Создать static файлы (CSS/JS)
-- [ ] Реализовать формы создания заявок
-- [ ] Добавить HTMX интерактивность
-- [ ] Интегрировать с WebSocket для real-time updates
-
----
-
-## 📈 Метрики Разработки
-
-### Время выполнения:
-- **Начало сессии:** Анализ 15 failing tests
-- **Конец сессии:** 41/41 PASSED (100%)
-- **Длительность:** ~3-4 часа систематической отладки
-
-### Изменённые файлы:
-- `backend/procurement/views.py` (queryset, finance manager lookup)
-- `backend/procurement/permissions.py` (related_name fix)
-- `backend/procurement/serializers.py` (поля в сериализаторах)
-- `backend/tests/procurement/test_api.py` (исправление ожиданий)
-- `backend/tests/procurement/test_workflow.py` (фикстуры, debug, ожидания)
-- `backend/eusrr_backend/settings_test.py` (новый файл)
-- `backend/tests/procurement/conftest.py` (новый файл)
-- `backend/pytest.ini` (DJANGO_SETTINGS_MODULE)
-- `PROCUREMENT_IMPLEMENTATION_PLAN.md` (обновление статуса)
-
-### Строки кода:
-- **Добавлено:** ~200 строк (test infrastructure + fixes)
-- **Изменено:** ~50 строк (bug fixes)
-- **Удалено:** ~30 строк (debug output, конфликты)
-
----
-
-## ✅ Чек-лист Завершения
-
-- [x] Все 41 тест проходят
-- [x] Нет pending/skipped тестов
-- [x] Debug код удалён
-- [x] Документация обновлена
-- [x] Коммиты созданы с понятными сообщениями
-- [x] Git история очищена (localcerts_backup.zip)
-- [x] Test infrastructure настроен (InMemoryChannelLayer)
-- [x] NotificationService интегрирован
-- [x] Queryset security проверен (approvers filter)
-- [x] Итоговый отчёт создан
-
----
-
-## 🎉 Заключение
-
-**Этапы 1-3 модуля Procurement успешно завершены!**
-
-Модуль имеет:
-- ✅ 8 моделей с полными связями
-- ✅ 50+ API endpoints с правами доступа
-- ✅ FSM workflow с многоуровневым согласованием
-- ✅ Интеграция с NotificationService (Web/Email/Telegram)
-- ✅ 41 тест с 100% покрытием основного функционала
-- ✅ Продуманная безопасность (queryset filtering)
-
-**Готов к продолжению разработки (Этап 4: Бюджеты).**
-
----
-
-**Автор отчёта:** GitHub Copilot
-**Дата создания:** 9 декабря 2025
-**Версия:** 1.0
diff --git a/REQUESTS_APP_STRUCTURE.md b/REQUESTS_APP_STRUCTURE.md
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/CELERY_NEXT_STEPS.md b/backend/CELERY_NEXT_STEPS.md
deleted file mode 100644
index ae799d96..00000000
--- a/backend/CELERY_NEXT_STEPS.md
+++ /dev/null
@@ -1,201 +0,0 @@
-# Celery Integration - Следующие шаги
-
-## ✅ Что уже сделано
-
-1. ✅ Добавлены зависимости в `requirements.txt`:
- - `celery==5.5.3`
- - `redis==5.2.1`
- - `django-celery-beat==2.7.0`
- - `django-celery-results==2.5.1`
- - `flower==2.0.1`
-
-2. ✅ Создан файл конфигурации `eusrr_backend/celery.py`
- - Настроена автоматическая регистрация задач
- - Добавлен debug task для тестирования
-
-3. ✅ Обновлен `eusrr_backend/__init__.py`
- - Автоматический импорт celery_app при запуске Django
-
-4. ✅ Добавлены настройки Celery в `settings.py`:
- - `CELERY_BROKER_URL` - Redis как брокер сообщений
- - `CELERY_RESULT_BACKEND` - Redis для хранения результатов
- - Настройка роутинга задач по очередям
- - Таймауты и лимиты для workers
-
-5. ✅ Добавлены приложения в `INSTALLED_APPS`:
- - `django_celery_beat` - для периодических задач
- - `django_celery_results` - для хранения результатов
-
-6. ✅ Созданы Celery задачи в `notifications/tasks.py`:
- - `send_notification_task` - отправка одного уведомления
- - `send_bulk_notifications_task` - массовая отправка
- - `cleanup_old_notifications_task` - очистка старых уведомлений
- - `send_digest_email_task` - email-дайджесты
-
-## 🔄 Следующие шаги (выполнить вручную)
-
-### Шаг 1: Установка зависимостей
-
-Запустите задачу в VS Code:
-- **Pip: install requirements**
-
-Или вручную:
-```bash
-cd backend
-..\.venv\Scripts\pip install -r requirements.txt
-```
-
-### Шаг 2: Установка и запуск Redis
-
-#### Вариант 1: Windows (рекомендуется Memurai)
-1. Скачайте Memurai (Redis для Windows): https://www.memurai.com/
-2. Установите и запустите как службу Windows
-3. Redis будет доступен на `localhost:6379`
-
-#### Вариант 2: Docker
-```bash
-docker run -d -p 6379:6379 --name redis redis:7-alpine
-```
-
-#### Проверка Redis:
-```bash
-redis-cli ping
-# Должно вернуть: PONG
-```
-
-### Шаг 3: Применение миграций
-
-Запустите:
-```bash
-cd backend
-migrate_celery.bat
-```
-
-Или вручную:
-```bash
-..\.venv\Scripts\python manage.py migrate django_celery_beat
-..\.venv\Scripts\python manage.py migrate django_celery_results
-```
-
-### Шаг 4: Тестирование Celery Worker
-
-В отдельном терминале запустите worker:
-```bash
-cd backend
-..\.venv\Scripts\celery -A eusrr_backend worker -l info --pool=solo
-```
-
-**Примечание**: На Windows используйте `--pool=solo` или `--pool=gevent`
-
-### Шаг 5: Тестирование отправки задачи
-
-В Django shell:
-```python
-..\.venv\Scripts\python manage.py shell
-
-from celery import current_app
-current_app.send_task('eusrr_backend.debug_task')
-```
-
-Вы должны увидеть выполнение задачи в логах worker.
-
-### Шаг 6: Интеграция с NotificationService
-
-Обновите `notifications/services.py`:
-```python
-from notifications.tasks import send_notification_task, send_bulk_notifications_task
-
-class NotificationService:
- @staticmethod
- def create_notification(...):
- # Вместо синхронного создания
- send_notification_task.delay(
- notification_type=notification_type,
- user_id=user.id,
- title=title,
- message=message,
- link=link,
- sender_id=sender.id if sender else None,
- metadata=metadata,
- )
-```
-
-### Шаг 7: Запуск Flower (мониторинг)
-
-```bash
-cd backend
-..\.venv\Scripts\celery -A eusrr_backend flower
-```
-
-Откройте http://localhost:5555 для просмотра статистики задач.
-
-### Шаг 8: Настройка периодических задач
-
-В Django Admin:
-1. Перейдите в "Periodic Tasks"
-2. Добавьте задачу для `notifications.cleanup_old_notifications`
-3. Настройте интервал (например, раз в день)
-
-Или программно в `eusrr_backend/celery.py`:
-```python
-from celery.schedules import crontab
-
-app.conf.beat_schedule = {
- 'cleanup-old-notifications': {
- 'task': 'notifications.cleanup_old_notifications',
- 'schedule': crontab(hour=3, minute=0), # Каждый день в 3:00
- 'args': (90,) # Удалять уведомления старше 90 дней
- },
-}
-```
-
-Запустите beat scheduler:
-```bash
-..\.venv\Scripts\celery -A eusrr_backend beat -l info
-```
-
-## 📊 Ожидаемый результат
-
-**До внедрения Celery:**
-- Отправка сообщения в группу с 8 участниками: ~500ms
-- Блокировка API response до отправки всех уведомлений
-
-**После внедрения Celery:**
-- Отправка сообщения: <50ms (только создание задачи)
-- Уведомления обрабатываются асинхронно
-- API мгновенно отвечает пользователю
-
-## 🔍 Отладка
-
-### Проверка статуса Celery
-```bash
-..\.venv\Scripts\celery -A eusrr_backend inspect active
-..\.venv\Scripts\celery -A eusrr_backend inspect stats
-```
-
-### Проверка задач в очереди
-```bash
-..\.venv\Scripts\celery -A eusrr_backend inspect reserved
-```
-
-### Логи
-- Worker логи: консоль где запущен worker
-- Flower: http://localhost:5555
-- Django логи: стандартные логи приложения
-
-## 📚 Документация
-
-- Подробная документация: `docs/guides/CELERY_SETUP.md`
-- План внедрения (5 дней): `docs/in-progress/CELERY_INTEGRATION_PLAN.md`
-- Celery docs: https://docs.celeryq.dev/
-
-## ⚠️ Production Deployment
-
-Для production:
-1. Используйте Redis Cluster или Redis Sentinel
-2. Запускайте несколько workers с supervisord/systemd
-3. Настройте мониторинг (Flower + Prometheus)
-4. Добавьте retry policies и dead letter queue
-5. Настройте автоматический перезапуск workers
-
-См. `docs/in-progress/CELERY_INTEGRATION_PLAN.md` для деталей.
diff --git a/backend/api/v1/feed/views.py b/backend/api/v1/feed/views.py
index 97fe52ac..510e1cb2 100644
--- a/backend/api/v1/feed/views.py
+++ b/backend/api/v1/feed/views.py
@@ -7,6 +7,7 @@ from django.db.models import BooleanField, Count, Exists, F, OuterRef, Subquery,
from employees.constants import DeptPerm
from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE
from feed.models import Comment, Post, PostLike
+from feed.notifications import notify_post_reaction
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
@@ -227,6 +228,8 @@ class PostViewSet(viewsets.ModelViewSet):
_, created = PostLike.objects.get_or_create(post=post, user=request.user)
if created:
Post.objects.filter(pk=post.pk).update(likes_count=F("likes_count") + 1)
+ # Отправляем уведомление автору публикации
+ notify_post_reaction(post, request.user)
post.refresh_from_db(fields=["likes_count"])
return Response(
{"liked": True, "likes_count": post.likes_count}, status=status.HTTP_200_OK
diff --git a/backend/api/v1/search/views.py b/backend/api/v1/search/views.py
index d664c789..7de80fde 100644
--- a/backend/api/v1/search/views.py
+++ b/backend/api/v1/search/views.py
@@ -316,6 +316,11 @@ def search_api_view(request: DRFRequest) -> Response:
for search_result in search_results:
obj = search_result.object
+
+ # Пропускаем удаленные объекты
+ if obj is None:
+ continue
+
model_name = _get_model_name(obj)
# Фильтрация по правам доступа
diff --git a/backend/bots/__init__.py b/backend/bots/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/bots/admin.py b/backend/bots/admin.py
deleted file mode 100644
index 8c38f3f3..00000000
--- a/backend/bots/admin.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.contrib import admin
-
-# Register your models here.
diff --git a/backend/bots/apps.py b/backend/bots/apps.py
deleted file mode 100644
index 733cbca5..00000000
--- a/backend/bots/apps.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from django.apps import AppConfig
-
-
-class BotsConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'bots'
diff --git a/backend/bots/filters.py b/backend/bots/filters.py
deleted file mode 100644
index b22ef213..00000000
--- a/backend/bots/filters.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# bots/filters.py
-from aiogram import types, F
-from aiogram.filters import BaseFilter
-from asgiref.sync import sync_to_async
-from bots.models import BotSubscriber
-
-
-class IsBoundSubscriber_tg(BaseFilter):
- """
- Пропускает только тех, чей telegram_id уже привязан.
- """
-
- async def __call__(self, event: types.Message | types.CallbackQuery) -> bool:
- user_id = event.from_user.id
- return await sync_to_async(
- BotSubscriber.objects.filter(telegram_id=user_id).exists
- )()
-
-
-class IsEmployeeActive(BaseFilter):
- """
- Пропускает только работающих сотрудников
- (последний кадровый статус — активный).
- """
-
- async def __call__(self, event: types.Message | types.CallbackQuery) -> bool:
- user_id = event.from_user.id
-
- # Получаем подписчика вместе с юзером
- subscriber = await sync_to_async(
- lambda: BotSubscriber.objects.select_related('user')
- .filter(telegram_id=user_id)
- .first()
- )()
-
- if not subscriber or not subscriber.user:
- return False
-
- # Проверяем свойство is_actually_active у Employee
- return await sync_to_async(lambda u: u.is_actually_active)(subscriber.user)
diff --git a/backend/bots/management/__init__.py b/backend/bots/management/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/bots/management/commands/__init__.py b/backend/bots/management/commands/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/bots/management/commands/start_telegram_bot.py b/backend/bots/management/commands/start_telegram_bot.py
deleted file mode 100644
index 1fdd9a38..00000000
--- a/backend/bots/management/commands/start_telegram_bot.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from django.core.management.base import BaseCommand
-from bots.services.telegram import run_telegram_bot
-
-
-class Command(BaseCommand):
- help = "Запуск Telegram-бота для рассылки и привязки сотрудников"
-
- def handle(self, *args, **options):
- run_telegram_bot()
diff --git a/backend/bots/migrations/0001_initial.py b/backend/bots/migrations/0001_initial.py
deleted file mode 100644
index 4e16fdc9..00000000
--- a/backend/bots/migrations/0001_initial.py
+++ /dev/null
@@ -1,34 +0,0 @@
-# Generated by Django 5.2.4 on 2025-07-25 11:08
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- initial = True
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.CreateModel(
- name='BotSubscriber',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('telegram_id', models.BigIntegerField(blank=True, null=True, unique=True, verbose_name='Telegram ID')),
- ('whatsapp_id', models.CharField(blank=True, max_length=64, null=True, unique=True, verbose_name='WhatsApp ID')),
- ('is_active', models.BooleanField(default=True)),
- ('subscribed_at', models.DateTimeField(auto_now_add=True)),
- ('registration_token', models.CharField(blank=True, max_length=64, null=True, unique=True, verbose_name='Токен для привязки')),
- ('token_created', models.DateTimeField(blank=True, null=True, verbose_name='Дата генерации токена')),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bot_subscriptions', to=settings.AUTH_USER_MODEL)),
- ],
- options={
- 'verbose_name': 'Подписчик бота',
- 'verbose_name_plural': 'Подписчики ботов',
- },
- ),
- ]
diff --git a/backend/bots/migrations/0002_remove_botsubscriber_is_active_and_more.py b/backend/bots/migrations/0002_remove_botsubscriber_is_active_and_more.py
deleted file mode 100644
index d449b398..00000000
--- a/backend/bots/migrations/0002_remove_botsubscriber_is_active_and_more.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# Generated by Django 5.2.4 on 2025-07-28 06:37
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('bots', '0001_initial'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name='botsubscriber',
- name='is_active',
- ),
- migrations.RemoveField(
- model_name='botsubscriber',
- name='registration_token',
- ),
- migrations.RemoveField(
- model_name='botsubscriber',
- name='token_created',
- ),
- migrations.AddField(
- model_name='botsubscriber',
- name='unsubscribed_at',
- field=models.DateTimeField(blank=True, null=True, verbose_name='Дата отвязки'),
- ),
- migrations.AlterField(
- model_name='botsubscriber',
- name='subscribed_at',
- field=models.DateTimeField(auto_now_add=True, verbose_name='Дата привязки'),
- ),
- migrations.AlterField(
- model_name='botsubscriber',
- name='user',
- field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='bot_subscription', to=settings.AUTH_USER_MODEL),
- ),
- ]
diff --git a/backend/bots/migrations/0003_remove_botsubscriber_subscribed_at_and_more.py b/backend/bots/migrations/0003_remove_botsubscriber_subscribed_at_and_more.py
deleted file mode 100644
index 2b991ffc..00000000
--- a/backend/bots/migrations/0003_remove_botsubscriber_subscribed_at_and_more.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# Generated by Django 5.2.4 on 2025-07-28 08:39
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('bots', '0002_remove_botsubscriber_is_active_and_more'),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name='botsubscriber',
- name='subscribed_at',
- ),
- migrations.RemoveField(
- model_name='botsubscriber',
- name='unsubscribed_at',
- ),
- ]
diff --git a/backend/bots/migrations/0004_botsubscriber_wechat_id.py b/backend/bots/migrations/0004_botsubscriber_wechat_id.py
deleted file mode 100644
index 23fa2776..00000000
--- a/backend/bots/migrations/0004_botsubscriber_wechat_id.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# Generated by Django 5.2.4 on 2025-08-04 13:00
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('bots', '0003_remove_botsubscriber_subscribed_at_and_more'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='botsubscriber',
- name='wechat_id',
- field=models.CharField(blank=True, max_length=64, null=True, unique=True, verbose_name='WeChat ID'),
- ),
- ]
diff --git a/backend/bots/migrations/__init__.py b/backend/bots/migrations/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/bots/models.py b/backend/bots/models.py
deleted file mode 100644
index 383a11a3..00000000
--- a/backend/bots/models.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# bots/models.py
-import uuid
-from django.db import models
-from django.contrib.auth import get_user_model
-
-User = get_user_model()
-
-
-class BotSubscriber(models.Model):
- user = models.OneToOneField(
- User,
- on_delete=models.CASCADE,
- related_name='bot_subscription'
- )
- telegram_id = models.BigIntegerField(
- 'Telegram ID',
- null=True, blank=True,
- unique=True
- )
- whatsapp_id = models.CharField(
- 'WhatsApp ID',
- max_length=64,
- null=True, blank=True,
- unique=True
- )
- wechat_id = models.CharField(
- 'WeChat ID',
- max_length=64,
- null=True, blank=True,
- unique=True
- )
-
- class Meta:
- verbose_name = 'Подписчик бота'
- verbose_name_plural = 'Подписчики ботов'
-
- def __str__(self):
- return f'{self.user} (TG={self.telegram_id or "-"} / WA={self.whatsapp_id or "-"} / WC={self.wechat_id or "-"})'
diff --git a/backend/bots/services/telegram.py b/backend/bots/services/telegram.py
deleted file mode 100644
index 11ecf356..00000000
--- a/backend/bots/services/telegram.py
+++ /dev/null
@@ -1,441 +0,0 @@
-import os
-import logging
-from datetime import timedelta
-
-from dotenv import load_dotenv
-
-import django
-from django.conf import settings
-from django.db import models
-from django.utils import timezone
-
-from aiogram import Bot, Dispatcher, types, F
-from aiogram.filters import CommandStart
-from aiogram.types import (
- InlineKeyboardMarkup, InlineKeyboardButton,
- ReplyKeyboardMarkup, KeyboardButton,
- FSInputFile,
-)
-from aiogram.exceptions import TelegramBadRequest
-from asgiref.sync import sync_to_async
-from apscheduler.schedulers.asyncio import AsyncIOScheduler
-
-from django.contrib.auth import get_user_model
-
-from calendar_app.models import CalendarEvent
-from bots.models import BotSubscriber
-from bots.filters import IsBoundSubscriber_tg, IsEmployeeActive
-from documents.models import Document, DocumentAcknowledgement
-
-# Если запускается вне manage.py, настраиваем Django
-if not settings.configured:
- os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
- django.setup()
-
-# Логирование
-logging.basicConfig(level=logging.INFO)
-logger = logging.getLogger(__name__)
-
-# Загрузка .env
-load_dotenv()
-BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
-
-# Инициализация бота и диспетчера
-bot = Bot(token=BOT_TOKEN)
-dp = Dispatcher()
-
-# Модель сотрудника
-Employee = get_user_model()
-
-# Отделы для inline‑меню
-DEPARTMENTS = {
- "department_manager": {
- "title": "Менеджеры",
- "groups": [
- {"name": "Группа 1", "link": "https://t.me/manager_group"},
- {"name": "Рабочий чат", "link": "https://t.me/manager_workchat"},
- ],
- },
- "department_tech": {
- "title": "Технари",
- "groups": [
- {"name": "Группа 1", "link": "https://t.me/tech_group"},
- {"name": "Рабочий чат", "link": "https://t.me/tech_workchat"},
- ],
- },
-}
-
-# — Клавиатуры —
-
-
-def get_contact_kb() -> ReplyKeyboardMarkup:
- return ReplyKeyboardMarkup(
- keyboard=[[
- KeyboardButton(text="📲 Поделиться контактом", request_contact=True)
- ]],
- resize_keyboard=True,
- one_time_keyboard=True,
- )
-
-
-def get_department_kb() -> InlineKeyboardMarkup:
- buttons = [
- InlineKeyboardButton(text=dept["title"], callback_data=key)
- for key, dept in DEPARTMENTS.items()
- ]
- return InlineKeyboardMarkup(inline_keyboard=[buttons])
-
-
-def main_menu_kb() -> InlineKeyboardMarkup:
- return InlineKeyboardMarkup(inline_keyboard=[[
- InlineKeyboardButton(text="📅 Календарь",
- callback_data="menu_calendar"),
- InlineKeyboardButton(text="📊 Статус", callback_data="menu_status"),
- InlineKeyboardButton(text="📄 Документы", callback_data="menu_docs"),
- ]])
-
-
-def department_menu_kb() -> InlineKeyboardMarkup:
- return InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text="📅 На сегодня",
- callback_data="cal_today")],
- [InlineKeyboardButton(text="🗓 На неделю",
- callback_data="cal_week")],
- [InlineKeyboardButton(text="📆 На месяц",
- callback_data="cal_month")],
- [InlineKeyboardButton(text="◀️ Главное меню",
- callback_data="go_main")],
- ])
-
-
-async def safe_edit(
- call: types.CallbackQuery,
- text: str,
- inline_kb: InlineKeyboardMarkup | None = None,
- **kwargs
-):
- """
- Редактирует call.message:
- - если передан inline_kb — edit_text,
- - иначе — answer.
- """
- msg = call.message
- try:
- if inline_kb:
- await msg.edit_text(text, reply_markup=inline_kb, **kwargs)
- else:
- await msg.answer(text, **kwargs)
- except TelegramBadRequest as e:
- await call.answer(text=f"Загрузка...", cache_time=10)
- # await msg.answer(text, reply_markup=inline_kb, **kwargs)
-
-# — ORM‑хелперы —
-
-
-@sync_to_async
-def find_employee_by_phone(phone: str):
- return Employee.objects.filter(telegram__endswith=phone[-10:]).first()
-
-
-@sync_to_async
-def find_employee_by_username(username: str):
- uname = username.lower().lstrip('@')
- return Employee.objects.filter(
- models.Q(telegram__iexact=uname) |
- models.Q(telegram__iexact='@' + uname)
- ).first()
-
-
-@sync_to_async
-def bind_telegram(user, tg_id: int):
- BotSubscriber.objects.filter(telegram_id=tg_id).exclude(user=user)\
- .update(telegram_id=None)
- return BotSubscriber.objects.update_or_create(
- user=user, defaults={"telegram_id": tg_id}
- )
-
-
-@sync_to_async
-def _get_subscriber_user(tg_id: int):
- sub = BotSubscriber.objects.select_related("user")\
- .filter(telegram_id=tg_id).first()
- return sub.user if sub and sub.user else None
-
-
-@sync_to_async
-def _get_user_documents(user):
- from django.db.models import Q
- return list(
- Document.objects.filter(Q(sent_to_all=True) | Q(recipients=user))
- .distinct().order_by('-uploaded_at')
- )
-
-
-@sync_to_async
-def _has_acknowledged(user, document):
- return DocumentAcknowledgement.objects.filter(user=user, document=document).exists()
-
-
-@sync_to_async
-def _acknowledge(user, document):
- return DocumentAcknowledgement.objects.get_or_create(user=user, document=document)
-
-
-@sync_to_async
-def _get_one_time_events(date):
- return list(CalendarEvent.objects.filter(
- recurrence=CalendarEvent.ONE_TIME, date=date
- ))
-
-
-@sync_to_async
-def _get_annual_events():
- return list(CalendarEvent.objects.filter(recurrence=CalendarEvent.ANNUAL))
-
-
-@sync_to_async
-def _get_subscribers():
- return list(BotSubscriber.objects.filter(telegram_id__isnull=False))
-
-# — Ежедневная рассылка —
-
-
-async def send_daily_notifications():
- today = timezone.localdate()
- one_time = await _get_one_time_events(today)
- annual_all = await _get_annual_events()
- annual_today = [
- ev for ev in annual_all
- if (ev.date.month, ev.date.day) == (today.month, today.day)
- ]
- events = one_time + annual_today
- if not events:
- logger.info("Нет событий на сегодня.")
- return
-
- subs = await _get_subscribers()
- for sub in subs:
- for ev in events:
- text = (
- f"📅 *{ev.title}*\n"
- f"Дата: `{ev.date:%d.%m.%Y}`\n"
- f"{ev.description or ''}"
- )
- try:
- await bot.send_message(
- sub.telegram_id,
- text,
- parse_mode="Markdown",
- )
- except:
- logger.exception(f"Не удалось отправить {sub.telegram_id}")
-
-scheduler = AsyncIOScheduler(timezone=settings.TIME_ZONE)
-scheduler.add_job(send_daily_notifications, "cron", hour=9, minute=0)
-
-
-async def on_startup_dispatcher():
- logger.info("Запуск APScheduler…")
- scheduler.start()
-
-dp.startup.register(on_startup_dispatcher)
-
-# — Хендлеры бота —
-
-
-@dp.message(CommandStart())
-async def cmd_start(msg: types.Message):
- await msg.answer(
- "Здравствуйте! Чтобы подключиться, пожалуйста, поделитесь контактом:",
- reply_markup=get_contact_kb()
- )
-
-
-@dp.message(lambda m: m.contact is not None)
-async def on_contact(msg: types.Message):
- phone = msg.contact.phone_number
- tg_id = msg.from_user.id
- user = (
- await find_employee_by_phone(phone)
- or (msg.from_user.username and await find_employee_by_username(msg.from_user.username))
- )
- if not user:
- return await msg.answer("❌ Профиль не найден.")
- await bind_telegram(user, tg_id)
- await msg.answer(
- f"✅ Спасибо, {user.first_name}! Выберите отдел:",
- reply_markup=get_department_kb()
- )
-
-
-@dp.callback_query(F.data.startswith("department_"), IsBoundSubscriber_tg(), IsEmployeeActive())
-async def department_selected(call: types.CallbackQuery):
- dept = DEPARTMENTS.get(call.data)
- text = (
- f"*{dept['title']}*:\n" +
- "\n".join(f"• [{g['name']}]({g['link']})" for g in dept["groups"])
- ) if dept else "Неизвестный отдел."
- await safe_edit(call, text, inline_kb=main_menu_kb(), parse_mode="Markdown")
- await call.answer()
-
-
-@dp.callback_query(F.data == "menu_calendar", IsBoundSubscriber_tg(), IsEmployeeActive())
-async def menu_calendar(call: types.CallbackQuery):
- await safe_edit(call, "Выберите период для просмотра:", inline_kb=department_menu_kb())
- await call.answer()
-
-
-@dp.callback_query(F.data == "menu_status", IsBoundSubscriber_tg(), IsEmployeeActive())
-async def menu_status(call: types.CallbackQuery):
- user = await _get_subscriber_user(call.from_user.id)
- status = await sync_to_async(lambda u: u.employment_status)(user) if user else "неизвестен"
- text = f"Ваш статус: *{status}*"
- await safe_edit(call, text, inline_kb=main_menu_kb(), parse_mode="Markdown")
- # await call.answer(cache_time=60, text="Смотрите статус в сообщении")
-
-
-@dp.callback_query(F.data == "cal_today", IsBoundSubscriber_tg(), IsEmployeeActive())
-async def calendar_today(call: types.CallbackQuery):
- today = timezone.localdate()
- evs = (await _get_one_time_events(today)) + [
- ev for ev in await _get_annual_events()
- if (ev.date.month, ev.date.day) == (today.month, today.day)
- ]
- text = ("Событий на сегодня нет." if not evs
- else "📅 События сегодня:\n" + "\n".join(f"• {e.title} ({e.date:%d.%m})" for e in evs))
- kb = InlineKeyboardMarkup(inline_keyboard=[[
- InlineKeyboardButton(text="◀️ Главное меню", callback_data="go_main")
- ]])
- await safe_edit(call, text, inline_kb=kb)
- await call.answer()
-
-
-@dp.callback_query(F.data == "cal_week", IsBoundSubscriber_tg(), IsEmployeeActive())
-async def calendar_week(call: types.CallbackQuery):
- start, end = timezone.localdate(), timezone.localdate() + timedelta(days=6)
- one = await _get_one_time_events(start)
- ann = await _get_annual_events()
- evs = one + [
- ev for ev in ann
- if (start.month, start.day) <= (ev.date.month, ev.date.day) <= (end.month, end.day)
- ]
- text = ("Событий на эту неделю нет." if not evs
- else f"📅 С {start:%d.%m} по {end:%d.%m}:\n" + "\n".join(f"• {e.title} ({e.date:%d.%m})" for e in evs))
- kb = InlineKeyboardMarkup(inline_keyboard=[[
- InlineKeyboardButton(text="◀️ Главное меню", callback_data="go_main")
- ]])
- await safe_edit(call, text, inline_kb=kb)
- await call.answer()
-
-
-@dp.callback_query(F.data == "cal_month", IsBoundSubscriber_tg(), IsEmployeeActive())
-async def calendar_month(call: types.CallbackQuery):
- today = timezone.localdate()
- start = today.replace(day=1)
- nm = start.replace(month=(start.month % 12) + 1,
- year=start.year + (start.month // 12))
- end = nm - timedelta(days=1)
- one = await _get_one_time_events(start)
- ann = await _get_annual_events()
- evs = one + [
- ev for ev in ann
- if (start.month, start.day) <= (ev.date.month, ev.date.day) <= (end.month, end.day)
- ]
- text = ("Событий в этом месяце нет." if not evs
- else f"📅 {today:%B %Y}:\n" + "\n".join(f"• {e.title} ({e.date:%d.%m})" for e in evs))
- kb = InlineKeyboardMarkup(inline_keyboard=[[
- InlineKeyboardButton(text="◀️ Главное меню", callback_data="go_main")
- ]])
- await safe_edit(call, text, inline_kb=kb)
- await call.answer()
-
-
-@dp.callback_query(F.data == "go_main", IsBoundSubscriber_tg(), IsEmployeeActive())
-async def go_main(call: types.CallbackQuery):
- await safe_edit(call, "Главное меню:", inline_kb=main_menu_kb())
- await call.answer()
-
-# — Документы через бот —
-
-
-@dp.callback_query(F.data == "menu_docs", IsBoundSubscriber_tg(), IsEmployeeActive())
-async def menu_docs(call: types.CallbackQuery):
- user = await _get_subscriber_user(call.from_user.id)
- docs = await _get_user_documents(user) if user else []
- if not docs:
- text = "У вас нет доступных документов."
- kb = InlineKeyboardMarkup(inline_keyboard=[[
- InlineKeyboardButton(text="◀️ Главное меню",
- callback_data="go_main")
- ]])
- else:
- text = "📄 Ваши документы:\nВыберите для просмотра:"
- rows = [
- [InlineKeyboardButton(
- text=d.title[:30] + ("…" if len(d.title) > 30 else ""),
- callback_data=f"doc_{d.id}"
- )]
- for d in docs[:5]
- ]
- rows.append([InlineKeyboardButton(
- text="◀️ Главное меню", callback_data="go_main")])
- kb = InlineKeyboardMarkup(inline_keyboard=rows)
-
- await safe_edit(call, text, inline_kb=kb)
- await call.answer()
-
-
-@dp.callback_query(F.data.startswith("doc_"), IsBoundSubscriber_tg(), IsEmployeeActive())
-async def show_document(call: types.CallbackQuery):
- user = await _get_subscriber_user(call.from_user.id)
- doc_id = int(call.data.split("_", 1)[1])
- doc = await sync_to_async(Document.objects.get)(pk=doc_id)
-
- caption = (
- f"*{doc.title}*\n\n"
- f"{doc.description or ''}\n\n"
- f"Загружен: {doc.uploaded_at:%d.%m.%Y %H:%M}"
- )
- already = await _has_acknowledged(user, doc)
-
- rows = []
- if not already:
- rows.append([InlineKeyboardButton(
- text="✅ Ознакомился", callback_data=f"ack_{doc.id}")])
- rows.append([InlineKeyboardButton(
- text="◀️ Документы", callback_data="menu_docs")])
- kb = InlineKeyboardMarkup(inline_keyboard=rows)
-
- file_input = FSInputFile(
- path=doc.file.path, filename=os.path.basename(doc.file.name))
- await bot.send_document(
- chat_id=call.from_user.id,
- document=file_input,
- caption=caption,
- parse_mode="Markdown",
- reply_markup=kb
- )
- await call.answer()
-
-
-@dp.callback_query(F.data.startswith("ack_"), IsBoundSubscriber_tg(), IsEmployeeActive())
-async def ack_document(call: types.CallbackQuery):
- user = await _get_subscriber_user(call.from_user.id)
- doc_id = int(call.data.split("_", 1)[1])
- await _acknowledge(user, await sync_to_async(Document.objects.get)(pk=doc_id))
-
- old = call.message.reply_markup.inline_keyboard
- new = [row for row in old if not any(
- btn.callback_data == f"ack_{doc_id}" for btn in row)]
- kb = InlineKeyboardMarkup(inline_keyboard=new)
-
- await call.message.edit_reply_markup(reply_markup=kb)
- await call.answer("Ознакомление зафиксировано.")
-
-
-def run_telegram_bot():
- dp.run_polling(bot)
-
-
-if __name__ == "__main__":
- run_telegram_bot()
diff --git a/backend/bots/tests.py b/backend/bots/tests.py
deleted file mode 100644
index 7ce503c2..00000000
--- a/backend/bots/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/backend/bots/utils.py b/backend/bots/utils.py
deleted file mode 100644
index d2e58702..00000000
--- a/backend/bots/utils.py
+++ /dev/null
@@ -1,15 +0,0 @@
-# from django.shortcuts import render, redirect
-# from django.contrib.auth.decorators import login_required
-# import secrets
-
-# @login_required
-# def generate_messenger_token(request):
-# user = request.user
-# user.registration_token = secrets.token_hex(16)
-# user.save(update_fields=['registration_token'])
-# telegram_link = f"https://t.me/your_bot?start={user.registration_token}"
-# # Можно также вернуть QR-код
-# return render(request, 'users/messenger_token.html', {
-# 'telegram_link': telegram_link,
-# 'token': user.registration_token,
-# })
diff --git a/backend/bots/views.py b/backend/bots/views.py
deleted file mode 100644
index 91ea44a2..00000000
--- a/backend/bots/views.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.shortcuts import render
-
-# Create your views here.
diff --git a/backend/calendar_app/apps.py b/backend/calendar_app/apps.py
index 62ed3041..8ae429be 100644
--- a/backend/calendar_app/apps.py
+++ b/backend/calendar_app/apps.py
@@ -17,7 +17,7 @@ class CalendarAppConfig(AppConfig):
Raises:
LookupError: Если приложение 'employees' или модель 'Employee' не найдены.
"""
- import calendar_app.notification_signals # noqa: F401
+ import calendar_app.notifications # Модуль уведомлений (signals регистрируются автоматически)
from calendar_app import signals # noqa: F401 - старая система
import calendar_app.rules # django-rules: регистрация предикатов и правил доступа
diff --git a/backend/calendar_app/notification_signals.py b/backend/calendar_app/notification_signals.py.old
similarity index 100%
rename from backend/calendar_app/notification_signals.py
rename to backend/calendar_app/notification_signals.py.old
diff --git a/backend/calendar_app/notifications/__init__.py b/backend/calendar_app/notifications/__init__.py
new file mode 100644
index 00000000..935b4690
--- /dev/null
+++ b/backend/calendar_app/notifications/__init__.py
@@ -0,0 +1,37 @@
+"""
+Модуль уведомлений для Calendar приложения.
+
+Публичный API:
+ - NotificationVerbs - константы глаголов уведомлений
+ - MessageTemplates - шаблоны сообщений
+ - ActionURLs - генераторы URL-адресов
+ - get_event_recipients - получение списка получателей для события
+
+Использование:
+ from calendar_app.notifications import get_event_recipients
+
+ # В коде:
+ recipients = get_event_recipients(event)
+"""
+
+from .config import (
+ NotificationVerbs,
+ MessageTemplates,
+ ActionURLs,
+ format_date,
+ format_changes,
+)
+from .handlers import get_event_recipients
+
+# Импортируем handlers и signals для регистрации
+from . import handlers # noqa: F401
+from . import signals # noqa: F401
+
+__all__ = [
+ 'NotificationVerbs',
+ 'MessageTemplates',
+ 'ActionURLs',
+ 'get_event_recipients',
+ 'format_date',
+ 'format_changes',
+]
diff --git a/backend/calendar_app/notifications/config.py b/backend/calendar_app/notifications/config.py
new file mode 100644
index 00000000..42c673fa
--- /dev/null
+++ b/backend/calendar_app/notifications/config.py
@@ -0,0 +1,126 @@
+"""
+Конфигурация уведомлений для модуля Calendar.
+
+Содержит:
+- NotificationVerbs - глаголы для уведомлений
+- MessageTemplates - шаблоны сообщений
+- ActionURLs - URL-адреса действий
+- Вспомогательные функции
+"""
+
+
+class NotificationVerbs:
+ """Глаголы (verbs) для уведомлений calendar."""
+
+ EVENT_CREATED = 'event_created'
+ EVENT_CHANGED = 'event_changed'
+ EVENT_CANCELLED = 'event_cancelled'
+ EVENT_REMINDER = 'event_reminder' # Для будущей реализации через Celery
+
+
+class MessageTemplates:
+ """Шаблоны сообщений для уведомлений calendar."""
+
+ @staticmethod
+ def event_created_company(event_title: str, event_date: str) -> str:
+ """Шаблон для нового события компании."""
+ return f'Добавлено событие компании: "{event_title}" ({event_date})'
+
+ @staticmethod
+ def event_created_department(
+ department_name: str,
+ event_title: str,
+ event_date: str
+ ) -> str:
+ """Шаблон для нового события отдела."""
+ return (
+ f'Добавлено событие отдела {department_name}: '
+ f'"{event_title}" ({event_date})'
+ )
+
+ @staticmethod
+ def event_created_title() -> str:
+ """Заголовок для нового события."""
+ return 'Новое событие в календаре'
+
+ @staticmethod
+ def event_changed(event_title: str, changes_text: str) -> str:
+ """Шаблон для изменения события."""
+ return f'Событие "{event_title}" изменено. Изменения: {changes_text}'
+
+ @staticmethod
+ def event_changed_title() -> str:
+ """Заголовок для изменения события."""
+ return 'Событие изменено'
+
+ @staticmethod
+ def event_cancelled(event_title: str, event_date: str) -> str:
+ """Шаблон для отмены события."""
+ return f'Событие "{event_title}" ({event_date}) отменено'
+
+ @staticmethod
+ def event_cancelled_title() -> str:
+ """Заголовок для отмены события."""
+ return 'Событие отменено'
+
+
+class ActionURLs:
+ """URL-адреса для действий с календарем."""
+
+ CALENDAR = '/calendar'
+
+ @staticmethod
+ def event_detail(event_id: int) -> str:
+ """
+ Возвращает URL-адрес события.
+
+ TODO: реализовать прямые ссылки на конкретные события
+ """
+ return f'/calendar?event={event_id}'
+
+
+# ===== Константы для полей =====
+
+FIELD_NAMES = {
+ 'title': 'название',
+ 'start_date': 'дата начала',
+ 'end_date': 'дата окончания',
+ 'start_time': 'время начала',
+ 'end_time': 'время окончания',
+ 'location': 'место проведения',
+}
+
+IMPORTANT_FIELDS = [
+ 'title', 'start_date', 'end_date',
+ 'start_time', 'end_time', 'location'
+]
+
+
+def format_date(date) -> str:
+ """
+ Форматирует дату в строку.
+
+ Args:
+ date: Объект date или datetime
+
+ Returns:
+ str: Отформатированная дата (например, "10.03.2026")
+ """
+ if not date:
+ return ''
+ return date.strftime('%d.%m.%Y')
+
+
+def format_changes(changed_fields: list[str]) -> str:
+ """
+ Форматирует список изменённых полей в текст.
+
+ Args:
+ changed_fields: Список имён полей
+
+ Returns:
+ str: Текстовое описание изменений через запятую
+ """
+ return ', '.join([
+ FIELD_NAMES.get(f, f) for f in changed_fields
+ ])
diff --git a/backend/calendar_app/notifications/handlers.py b/backend/calendar_app/notifications/handlers.py
new file mode 100644
index 00000000..a529f1ff
--- /dev/null
+++ b/backend/calendar_app/notifications/handlers.py
@@ -0,0 +1,174 @@
+"""
+Бизнес-логика отправки уведомлений для модуля Calendar.
+
+Функции:
+- get_event_recipients - определение получателей уведомлений
+- notify_event_created - уведомление о новом событии
+- notify_event_changed - уведомление об изменении события
+- notify_event_cancelled - уведомление об отмене события
+"""
+
+from django.contrib.auth import get_user_model
+from notifications.signals import notify
+
+from .config import (
+ NotificationVerbs,
+ MessageTemplates,
+ ActionURLs,
+ format_date,
+ format_changes,
+)
+
+Employee = get_user_model()
+
+
+def get_event_recipients(event) -> list:
+ """
+ Возвращает список получателей уведомлений для события.
+
+ Args:
+ event: Объект CalendarEvent
+
+ Returns:
+ list: Список пользователей (Employee):
+ - Для личного события - только владелец
+ - Для события компании - все активные сотрудники
+ - Для события отдела - сотрудники этого отдела
+ """
+ if event.is_personal:
+ # Личное событие - только владелец
+ return [event.employee] if event.employee else []
+
+ elif event.is_company:
+ # Событие компании - все активные сотрудники
+ return list(Employee.objects.filter(is_active=True))
+
+ elif event.department:
+ # Событие отдела - сотрудники через EmployeeDepartment
+ from employees.models import EmployeeDepartment
+ employee_ids = EmployeeDepartment.objects.filter(
+ department=event.department,
+ is_active=True
+ ).values_list('employee_id', flat=True)
+ return list(Employee.objects.filter(id__in=employee_ids, is_active=True))
+
+ return []
+
+
+def notify_event_created(event):
+ """
+ Отправляет уведомления о новом событии всем участникам.
+
+ Args:
+ event: Объект CalendarEvent
+ """
+ recipients = get_event_recipients(event)
+
+ # Исключаем создателя события
+ if event.created_by:
+ recipients = [r for r in recipients if r.id != event.created_by.id]
+
+ # Определяем тип события для сообщения
+ event_date = format_date(event.start_date)
+
+ if event.is_company:
+ description = MessageTemplates.event_created_company(event.title, event_date)
+ elif event.department:
+ description = MessageTemplates.event_created_department(
+ event.department.name,
+ event.title,
+ event_date
+ )
+ else:
+ # Личное событие - не должно попадать сюда (нет других получателей)
+ return
+
+ # Отправляем уведомления
+ for recipient in recipients:
+ notify.send(
+ sender=event.created_by,
+ recipient=recipient,
+ verb=NotificationVerbs.EVENT_CREATED,
+ action_object=event,
+ description=description,
+ action_url=ActionURLs.CALENDAR,
+ data={
+ 'title': MessageTemplates.event_created_title(),
+ '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: list[str]):
+ """
+ Отправляет уведомления об изменении события.
+
+ Args:
+ event: Объект CalendarEvent
+ changed_fields: Список изменённых полей
+ """
+ if not changed_fields:
+ return
+
+ recipients = get_event_recipients(event)
+
+ # Формируем сообщение об изменениях
+ changes_text = format_changes(changed_fields)
+
+ for recipient in recipients:
+ notify.send(
+ sender=None,
+ recipient=recipient,
+ verb=NotificationVerbs.EVENT_CHANGED,
+ action_object=event,
+ description=MessageTemplates.event_changed(event.title, changes_text),
+ action_url=ActionURLs.CALENDAR,
+ data={
+ 'title': MessageTemplates.event_changed_title(),
+ '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 notify_event_cancelled(event):
+ """
+ Отправляет уведомления об отмене (удалении) события.
+
+ Args:
+ event: Объект CalendarEvent
+ """
+ recipients = get_event_recipients(event)
+ event_date = format_date(event.start_date)
+
+ for recipient in recipients:
+ notify.send(
+ sender=None,
+ recipient=recipient,
+ verb=NotificationVerbs.EVENT_CANCELLED,
+ description=MessageTemplates.event_cancelled(event.title, event_date),
+ action_url=ActionURLs.CALENDAR,
+ data={
+ 'title': MessageTemplates.event_cancelled_title(),
+ '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,
+ }
+ )
diff --git a/backend/calendar_app/notifications/signals.py b/backend/calendar_app/notifications/signals.py
new file mode 100644
index 00000000..743abe9b
--- /dev/null
+++ b/backend/calendar_app/notifications/signals.py
@@ -0,0 +1,75 @@
+"""
+Django signals для автоматической генерации уведомлений в модуле Calendar.
+
+Обрабатывает события:
+- post_save для CalendarEvent - создание или изменение события
+- pre_save для CalendarEvent - отслеживание изменений полей
+- pre_delete для CalendarEvent - отмена (удаление) события
+"""
+
+from django.db.models.signals import post_save, pre_delete, pre_save
+from django.dispatch import receiver
+
+from ..models import CalendarEvent
+from .config import IMPORTANT_FIELDS
+from .handlers import (
+ notify_event_created,
+ notify_event_changed,
+ notify_event_cancelled,
+)
+
+
+@receiver(post_save, sender=CalendarEvent)
+def create_event_notifications(sender, instance, created, **kwargs):
+ """
+ Создает уведомления при создании или изменении события.
+
+ Уведомления отправляются:
+ - При создании - всем сотрудникам компании или отдела
+ - При изменении - участникам события (если изменились важные поля)
+
+ Уведомления отправляются через универсальную систему
+ channels.py → Celery → WebSocket/Email/Push
+ """
+ if created:
+ notify_event_created(instance)
+ else:
+ changed_fields = getattr(instance, '_changed_fields', [])
+ if changed_fields:
+ notify_event_changed(instance, changed_fields)
+
+
+@receiver(pre_save, sender=CalendarEvent)
+def track_event_changes(sender, instance, **kwargs):
+ """
+ Отслеживает изменения важных полей события для уведомлений.
+
+ Сохраняет список изменённых полей в атрибут _changed_fields,
+ который затем используется в post_save signal.
+ """
+ if instance.pk:
+ try:
+ old_event = CalendarEvent.objects.get(pk=instance.pk)
+ changed_fields = []
+
+ # Проверяем изменения важных полей
+ 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_signal(sender, instance, **kwargs):
+ """
+ Создает уведомления при удалении (отмене) события.
+
+ Уведомления отправляются через универсальную систему
+ channels.py → Celery → WebSocket/Email/Push
+ """
+ notify_event_cancelled(instance)
diff --git a/backend/check_event_params.py b/backend/check_event_params.py
deleted file mode 100644
index 53e8a0e4..00000000
--- a/backend/check_event_params.py
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env python
-"""Проверка параметров события в базе данных."""
-import os
-import sys
-import django
-
-# Setup Django
-sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
-django.setup()
-
-# ЯВНЫЙ импорт патча
-import schedule_patch # noqa
-
-from schedule.models import Event, Rule
-
-# Найдём событие по названию
-event = Event.objects.filter(title__icontains='ыфвыфыф').first()
-
-if event:
- print(f"=" * 80)
- print(f"Event ID: {event.id}")
- print(f"Title: {event.title}")
- print(f"Start: {event.start} (weekday: {event.start.weekday()})")
- print(f"End recurring: {event.end_recurring_period}")
- print(f"=" * 80)
-
- if event.rule:
- print(f"\nRule ID: {event.rule.id}")
- print(f"Frequency: {event.rule.frequency}")
- print(f"Params: {event.rule.params}")
- print(f"=" * 80)
-
- # Проверим occurrences
- from datetime import datetime
- from django.utils import timezone
- start = timezone.make_aware(datetime(2026, 2, 1, 0, 0, 0))
- end = timezone.make_aware(datetime(2026, 2, 28, 23, 59, 59))
- occurrences = event.get_occurrences(start, end)
-
- print(f"\nOccurrences в феврале 2026:")
- for occ in occurrences:
- print(f" - {occ.start.strftime('%Y-%m-%d %A')} {occ.start.strftime('%H:%M')}")
- print(f"=" * 80)
- else:
- print("НЕТ ПРАВИЛА!")
-else:
- print("Событие не найдено!")
diff --git a/backend/communications/MESSENGER_IMPLEMENTATION_COMPLETE.md b/backend/communications/MESSENGER_IMPLEMENTATION_COMPLETE.md
deleted file mode 100644
index 2290f6a7..00000000
--- a/backend/communications/MESSENGER_IMPLEMENTATION_COMPLETE.md
+++ /dev/null
@@ -1,345 +0,0 @@
-# Документация по обновлению мессенджера
-
-## Обзор
-
-Проведено полное обновление системы чатов 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/
/update/` - обновление названия/описания/аватара
-- `POST /communications/api/chat//pin/` - закрепление/открепление чата
-- `POST /communications/api/chat//notifications/` - управление уведомлениями
-
-#### Действия с сообщениями
-- `POST /communications/api/message//edit/` - редактирование с историей
-- `POST /communications/api/message//delete/` - мягкое удаление
-- `POST /communications/api/message//react/` - добавить реакцию
-- `POST /communications/api/message//unreact/` - удалить реакцию
-- `POST /communications/api/message//pin/` - закрепить сообщение
-
-#### Вложения
-- `POST /communications/api/attachment/upload/` - загрузка файлов
-
-#### Пересылка и ответы
-- `POST /communications/api/message//forward/` - пересылка с цепочкой
-- `POST /communications/api/message//reply/` - ответ (inline/quote/thread)
-
-#### Участники
-- `POST /communications/api/chat//member/add/` - добавить участника
-- `POST /communications/api/chat//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 %}
-
-
- {% 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
diff --git a/backend/communications/MESSENGER_IMPROVEMENTS.md b/backend/communications/MESSENGER_IMPROVEMENTS.md
deleted file mode 100644
index 0d8c368a..00000000
--- a/backend/communications/MESSENGER_IMPROVEMENTS.md
+++ /dev/null
@@ -1,865 +0,0 @@
-# Улучшения модели мессенджера
-
-## 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 │
- │ ↳ В ответ на сообщение из │
- │ "Общий чат компании" │
- ├─────────────────────────────────┤
- │ > Исходное сообщение... │
- │ Ответ... │
- └─────────────────────────────────┘
- ```
-
-### Контекстное меню сообщения (расширенное)
-- Ответить
-- Ответить в другом чате →
-- Переслать →
- - В избранное
- - В другой чат...
- - В несколько чатов...
-- Отправить в чужой чат →
-- Скопировать
-- Редактировать (свои)
-- Удалить (свои/админ)
-- Закрепить (админ)
-- Пожаловаться
-
-### Права и разрешения (админ панель чата)
-- Настройки чата:
- - Кто может писать (все/только участники/только админы)
- - Разрешить внешние сообщения (да/нет/с модерацией)
- - Кто может приглашать участников
-- Управление внешними отправителями:
- - Список пользователей с правами
- - Добавить/удалить права
- - Установить срок действия
- - Модерация ожидающих сообщений
-
diff --git a/backend/communications/apps.py b/backend/communications/apps.py
index 4ff47043..72f5775a 100644
--- a/backend/communications/apps.py
+++ b/backend/communications/apps.py
@@ -8,7 +8,7 @@ class CommunicationsConfig(AppConfig):
def ready(self):
import communications.signals
- import communications.notification_signals # Подключаем signals уведомлений
+ import communications.notifications # Модуль уведомлений (signals регистрируются автоматически)
import communications.rules # django-rules: регистрация предикатов и правил доступа
def create_main_global_chat(sender, **kwargs):
diff --git a/backend/communications/context_processors.py b/backend/communications/context_processors.py
deleted file mode 100644
index 2ed12500..00000000
--- a/backend/communications/context_processors.py
+++ /dev/null
@@ -1,49 +0,0 @@
-import datetime
-
-from django.db import models
-from django.db.models import Count, F, Q, Subquery, Value
-from django.db.models.functions import Coalesce
-from django.utils import timezone
-
-from .models import Chat, ChatReadState, Message
-
-
-def chat_unread_total(request):
- """
- Считает общее количество непрочитанных сообщений пользователя.
- Использует last_read_message_id (Telegram-style) вместо last_read_at.
- """
- if not request.user.is_authenticated:
- return {"chat_unread_total": 0}
-
- user = request.user
-
- # last_read_message_id для каждого чата текущего юзера
- last_read_msg_sq = ChatReadState.objects.filter(
- chat=models.OuterRef("pk"), user=user
- ).values("last_read_message_id")[:1]
-
- # доступные юзеру чаты
- deps = getattr(user, "departments", None)
- deps_qs = deps.all() if deps is not None else []
-
- chats_qs = (
- Chat.objects.filter(
- Q(type="global")
- | Q(type="private", participants=user)
- | Q(type="department", department__in=deps_qs)
- )
- .distinct()
- .annotate(last_read_msg_id=Coalesce(Subquery(last_read_msg_sq), Value(0)))
- .annotate(
- unread=Count(
- "messages",
- filter=Q(messages__id__gt=F("last_read_msg_id"))
- & ~Q(messages__author=user),
- distinct=True,
- )
- )
- )
-
- total = chats_qs.aggregate(total=models.Sum("unread"))["total"] or 0
- return {"chat_unread_total": total}
diff --git a/backend/communications/notification_signals.py b/backend/communications/notification_signals.py
deleted file mode 100644
index 111bdc4f..00000000
--- a/backend/communications/notification_signals.py
+++ /dev/null
@@ -1,307 +0,0 @@
-"""
-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, ChatUserSettings
-from notifications.signals import notify
-
-Employee = get_user_model()
-
-
-def should_send_notification(chat, user):
- """
- Проверяет, нужно ли отправлять уведомление пользователю для данного чата.
-
- Args:
- chat: Chat объект
- user: Employee объект
-
- Returns:
- bool: True если нужно отправить уведомление, False если пользователь отключил уведомления
- """
- try:
- settings = ChatUserSettings.objects.get(chat=chat, user=user)
- return settings.notifications_enabled
- except ChatUserSettings.DoesNotExist:
- # Если настройки не найдены, считаем что уведомления включены по умолчанию
- return True
-
-
-def get_users_with_notifications_enabled(chat, users):
- """
- Получает словарь пользователей с их настройками уведомлений.
- Оптимизированная версия - один запрос к БД вместо N.
-
- Args:
- chat: Chat объект
- users: QuerySet или список пользователей
-
- Returns:
- dict: {user_id: notifications_enabled}
- """
- if not users:
- return {}
-
- user_ids = [u.id for u in users]
-
- # Один запрос для всех пользователей
- settings = ChatUserSettings.objects.filter(
- chat=chat,
- user_id__in=user_ids
- ).values('user_id', 'notifications_enabled')
-
- # Создаем словарь для быстрого доступа
- settings_dict = {s['user_id']: s['notifications_enabled'] for s in settings}
-
- # Для пользователей без настроек - по умолчанию True
- return {uid: settings_dict.get(uid, True) for uid in user_ids}
-
-
-@receiver(post_save, sender=Message)
-def create_message_notifications(sender, instance, created, **kwargs):
- """
- Создает уведомления при создании нового сообщения.
-
- ОПТИМИЗАЦИЯ: Вся логика обработки вынесена в асинхронную Celery задачу,
- чтобы не блокировать сохранение сообщения.
-
- Обрабатывает:
- 1. Новое сообщение в чате (для всех участников кроме автора)
- 2. Упоминания (@username) в тексте
- 3. Ответ на сообщение (reply_to)
- """
- if not created or instance.is_system or instance.is_deleted:
- return
-
- # Отправляем уведомления напрямую - channels.py автоматически отправит через Celery
- _create_message_notifications_sync(instance)
-
-
-def _create_message_notifications_sync(instance):
- """
- Синхронная версия обработки уведомлений (fallback для разработки).
- Используется только когда Celery недоступен.
- """
-
- chat = instance.chat
- author = instance.author
- content = instance.content
-
- # Получаем всех участников чата кроме автора
- # Для announcement/channel/department используем get_participants
- if chat.type in ['announcement', 'channel', 'department', 'global']:
- participants = chat.get_participants.exclude(id=author.id)
- else:
- participants = chat.participants.exclude(id=author.id)
-
- # ОПТИМИЗАЦИЯ: Загружаем настройки уведомлений ОДНИМ запросом для всех участников
- participants_list = list(participants)
- if not participants_list:
- return # Нет получателей - выходим
-
- notification_settings = get_users_with_notifications_enabled(chat, participants_list)
-
- # 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_list and user.id != author.id:
- if notification_settings.get(user.id, True):
- notify.send(
- sender=author,
- recipient=user,
- verb='chat_mention',
- action_object=instance,
- description=truncate_message(content, 100),
- action_url='/messages',
- data={
- 'title': f'Вас упомянул {author.get_full_name() or author.username}',
- '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:
- if notification_settings.get(original_author.id, True):
- notify.send(
- sender=author,
- recipient=original_author,
- verb='chat_reply',
- action_object=instance,
- description=truncate_message(content, 100),
- action_url='/messages',
- data={
- 'title': f'{author.get_full_name() or author.username} ответил на ваше сообщение',
- '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 == 'announcement':
- notification_type = 'announcement_new_message'
- title = f'Новое объявление от {author.get_full_name() or author.username}'
- is_announcement = True
- else:
- notification_type = 'chat_new_message'
- if chat.type == 'private':
- title = f'Новое сообщение от {author.get_full_name() or author.username}'
- else:
- title = f'{author.get_full_name() or author.username} в {get_chat_name(chat)}'
- is_announcement = False
-
- for recipient in participants_list:
- if recipient.id in excluded_ids:
- continue
-
- if not notification_settings.get(recipient.id, True):
- continue
-
- metadata = {
- 'chat_id': chat.id,
- 'chat_name': get_chat_name(chat),
- 'message_id': instance.id,
- 'author_id': author.id,
- }
- if is_announcement:
- metadata['is_announcement'] = True
-
- notify.send(
- sender=author,
- recipient=recipient,
- verb=notification_type,
- action_object=instance,
- description=truncate_message(content, 150 if is_announcement else 100),
- action_url='/messages',
- data={**metadata, 'title': title},
- )
-
-
-@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()
-
- # ОПТИМИЗАЦИЯ: Получаем всех новых пользователей одним запросом
- new_users = Employee.objects.filter(id__in=pk_set)
-
- # ОПТИМИЗАЦИЯ: Загружаем настройки одним запросом
- notification_settings = get_users_with_notifications_enabled(chat, new_users)
-
- # Создаем уведомления для новых участников
- for user in new_users:
- # Не отправляем уведомление тому, кто сам себя добавил
- if added_by and user.id == added_by.id:
- continue
-
- # Проверяем настройки уведомлений (из предзагруженного словаря)
- if not notification_settings.get(user.id, True):
- continue
-
- notify.send(
- sender=added_by,
- recipient=user,
- verb='chat_added_to_chat',
- action_object=chat,
- description=f'Вы были добавлены в чат "{get_chat_name(chat)}"',
- action_url='/messages',
- data={
- 'title': 'Вас добавили в чат',
- 'added_by_id': added_by.id if added_by else None,
- }
- )
-
-
-# ===== Вспомогательные функции =====
-
-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 'Чат'
diff --git a/backend/communications/notifications/__init__.py b/backend/communications/notifications/__init__.py
new file mode 100644
index 00000000..ae50ed50
--- /dev/null
+++ b/backend/communications/notifications/__init__.py
@@ -0,0 +1,43 @@
+"""
+Модуль уведомлений для Communications приложения.
+
+Публичный API:
+ - NotificationVerbs - константы глаголов уведомлений
+ - MessageTemplates - шаблоны сообщений
+ - ActionURLs - генераторы URL-адресов
+ - extract_mentions - извлечение упоминаний из текста
+ - truncate_message - обрезка текста сообщения
+ - get_chat_name - получение названия чата
+ - get_users_with_notifications_enabled - проверка настроек уведомлений
+
+Использование:
+ from communications.notifications import extract_mentions, get_chat_name
+
+ # В коде:
+ mentions = extract_mentions(message.content)
+ chat_name = get_chat_name(chat)
+"""
+
+from .config import (
+ NotificationVerbs,
+ MessageTemplates,
+ ActionURLs,
+ extract_mentions,
+ truncate_message,
+ get_chat_name,
+)
+from .handlers import get_users_with_notifications_enabled
+
+# Импортируем handlers и signals для регистрации
+from . import handlers # noqa: F401
+from . import signals # noqa: F401
+
+__all__ = [
+ 'NotificationVerbs',
+ 'MessageTemplates',
+ 'ActionURLs',
+ 'extract_mentions',
+ 'truncate_message',
+ 'get_chat_name',
+ 'get_users_with_notifications_enabled',
+]
diff --git a/backend/communications/notifications/config.py b/backend/communications/notifications/config.py
new file mode 100644
index 00000000..e359038a
--- /dev/null
+++ b/backend/communications/notifications/config.py
@@ -0,0 +1,159 @@
+"""
+Конфигурация уведомлений для модуля Communications.
+
+Содержит:
+- NotificationVerbs - глаголы для уведомлений
+- MessageTemplates - шаблоны сообщений
+- ActionURLs - URL-адреса действий
+- Вспомогательные функции для обработки сообщений
+"""
+
+import re
+
+
+class NotificationVerbs:
+ """Глаголы (verbs) для уведомлений communications."""
+
+ NEW_MESSAGE = 'chat_new_message'
+ ANNOUNCEMENT = 'announcement_new_message'
+ MENTION = 'chat_mention'
+ REPLY = 'chat_reply'
+ ADDED_TO_CHAT = 'chat_added_to_chat'
+
+
+class MessageTemplates:
+ """Шаблоны сообщений для уведомлений communications."""
+
+ @staticmethod
+ def mention(author_name: str) -> str:
+ """Шаблон для упоминания пользователя."""
+ return f'Вас упомянул {author_name}'
+
+ @staticmethod
+ def reply(author_name: str) -> str:
+ """Шаблон для ответа на сообщение."""
+ return f'{author_name} ответил на ваше сообщение'
+
+ @staticmethod
+ def announcement(author_name: str) -> str:
+ """Шаблон для нового объявления."""
+ return f'Новое объявление от {author_name}'
+
+ @staticmethod
+ def private_message(author_name: str) -> str:
+ """Шаблон для приватного сообщения."""
+ return f'Новое сообщение от {author_name}'
+
+ @staticmethod
+ def group_message(author_name: str, chat_name: str) -> str:
+ """Шаблон для сообщения в группе."""
+ return f'{author_name} в {chat_name}'
+
+ @staticmethod
+ def added_to_chat(chat_name: str) -> str:
+ """Шаблон для добавления в чат."""
+ return f'Вы были добавлены в чат "{chat_name}"'
+
+ @staticmethod
+ def added_to_chat_title() -> str:
+ """Заголовок для уведомления о добавлении в чат."""
+ return 'Вас добавили в чат'
+
+
+class ActionURLs:
+ """URL-адреса для действий с сообщениями."""
+
+ MESSAGES = '/messages'
+
+ @staticmethod
+ def chat_detail(chat_id: int) -> str:
+ """
+ Возвращает URL-адрес чата.
+
+ TODO: реализовать прямые ссылки на конкретные чаты
+ """
+ return f'/messages?chat={chat_id}'
+
+ @staticmethod
+ def message_detail(chat_id: int, message_id: int) -> str:
+ """
+ Возвращает URL-адрес сообщения.
+
+ TODO: реализовать прямые ссылки на конкретные сообщения
+ """
+ return f'/messages?chat={chat_id}&message={message_id}'
+
+
+# ===== Вспомогательные функции =====
+
+def extract_mentions(text: str) -> list[str]:
+ """
+ Извлекает email'ы из упоминаний вида @email.
+
+ Args:
+ text: Текст сообщения
+
+ Returns:
+ list: Список email'ов без символа @
+
+ Examples:
+ >>> extract_mentions("Привет @user@example.com и @test@mail.com")
+ ['user@example.com', 'test@mail.com']
+ """
+ if not text:
+ return []
+
+ # Паттерн: @ + email формат
+ pattern = r'@([\w.+-]+@[\w.-]+\.[\w]+)'
+ mentions = re.findall(pattern, text)
+
+ return list(set(mentions)) # Уникальные значения
+
+
+def truncate_message(text: str, max_length: int = 100) -> str:
+ """
+ Обрезает текст сообщения до указанной длины.
+
+ Args:
+ text: Исходный текст
+ max_length: Максимальная длина (по умолчанию 100)
+
+ Returns:
+ Обрезанный текст с многоточием если нужно
+ """
+ if not text:
+ return ''
+
+ if len(text) <= max_length:
+ return text
+
+ return text[:max_length - 3] + '...'
+
+
+def get_chat_name(chat) -> str:
+ """
+ Возвращает название чата в зависимости от типа.
+
+ Args:
+ chat: Объект Chat
+
+ Returns:
+ str: Название чата
+ """
+ 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 'Чат'
diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py
new file mode 100644
index 00000000..cae8f8b9
--- /dev/null
+++ b/backend/communications/notifications/handlers.py
@@ -0,0 +1,220 @@
+"""
+Бизнес-логика отправки уведомлений для модуля Communications.
+
+Функции:
+- get_users_with_notifications_enabled - проверка настроек уведомлений
+- notify_new_message - уведомление о новом сообщении (с упоминаниями и ответами)
+- notify_chat_added - уведомление о добавлении в чат
+"""
+
+from django.contrib.auth import get_user_model
+from notifications.signals import notify
+
+from ..models import ChatUserSettings
+from .config import (
+ NotificationVerbs,
+ MessageTemplates,
+ ActionURLs,
+ extract_mentions,
+ truncate_message,
+ get_chat_name,
+)
+
+Employee = get_user_model()
+
+
+def get_users_with_notifications_enabled(chat, users) -> dict[int, bool]:
+ """
+ Получает словарь пользователей с их настройками уведомлений.
+ Оптимизированная версия - один запрос к БД вместо N.
+
+ Args:
+ chat: Chat объект
+ users: QuerySet или список пользователей
+
+ Returns:
+ dict: {user_id: notifications_enabled}
+ """
+ if not users:
+ return {}
+
+ user_ids = [u.id for u in users]
+
+ # Один запрос для всех пользователей
+ settings = ChatUserSettings.objects.filter(
+ chat=chat,
+ user_id__in=user_ids
+ ).values('user_id', 'notifications_enabled')
+
+ # Создаем словарь для быстрого доступа
+ settings_dict = {s['user_id']: s['notifications_enabled'] for s in settings}
+
+ # Для пользователей без настроек - по умолчанию True
+ return {uid: settings_dict.get(uid, True) for uid in user_ids}
+
+
+def notify_new_message(message):
+ """
+ Отправляет уведомления о новом сообщении в чате.
+
+ Обрабатывает три типа уведомлений:
+ 1. Упоминания (@username) - высокий приоритет
+ 2. Ответы на сообщения - средний приоритет
+ 3. Обычные сообщения в чате - низкий приоритет
+
+ Args:
+ message: Экземпляр модели Message
+ """
+ chat = message.chat
+ author = message.author
+ content = message.content
+
+ # Получаем всех участников чата кроме автора
+ if chat.type in ['announcement', 'channel', 'department', 'global']:
+ participants = chat.get_participants.exclude(id=author.id)
+ else:
+ participants = chat.participants.exclude(id=author.id)
+
+ # ОПТИМИЗАЦИЯ: Загружаем настройки уведомлений ОДНИМ запросом
+ participants_list = list(participants)
+ if not participants_list:
+ return # Нет получателей
+
+ notification_settings = get_users_with_notifications_enabled(chat, participants_list)
+
+ # Отслеживаем, кому уже отправили уведомления
+ notified_user_ids = set()
+
+ # 1. УПОМИНАНИЯ (@username)
+ mentioned_users = extract_mentions(content)
+ author_name = author.get_full_name() or author.username
+
+ if mentioned_users:
+ for email in mentioned_users:
+ try:
+ user = Employee.objects.get(email=email)
+ if user in participants_list and user.id != author.id:
+ if notification_settings.get(user.id, True):
+ notify.send(
+ sender=author,
+ recipient=user,
+ verb=NotificationVerbs.MENTION,
+ action_object=message,
+ description=truncate_message(content, 100),
+ action_url=ActionURLs.MESSAGES,
+ data={
+ 'title': MessageTemplates.mention(author_name),
+ 'chat_id': chat.id,
+ 'chat_name': get_chat_name(chat),
+ 'message_id': message.id,
+ 'author_id': author.id,
+ }
+ )
+ notified_user_ids.add(user.id)
+ except Employee.DoesNotExist:
+ continue
+
+ # 2. ОТВЕТЫ НА СООБЩЕНИЯ
+ if message.reply_to and message.reply_to.author_id != author.id:
+ original_author = message.reply_to.author
+
+ if original_author.id not in notified_user_ids:
+ if notification_settings.get(original_author.id, True):
+ notify.send(
+ sender=author,
+ recipient=original_author,
+ verb=NotificationVerbs.REPLY,
+ action_object=message,
+ description=truncate_message(content, 100),
+ action_url=ActionURLs.MESSAGES,
+ data={
+ 'title': MessageTemplates.reply(author_name),
+ 'chat_id': chat.id,
+ 'chat_name': get_chat_name(chat),
+ 'message_id': message.id,
+ 'reply_to_id': message.reply_to.id,
+ 'author_id': author.id,
+ }
+ )
+ notified_user_ids.add(original_author.id)
+
+ # 3. ОБЫЧНЫЕ УВЕДОМЛЕНИЯ О НОВОМ СООБЩЕНИИ
+ if chat.type == 'announcement':
+ notification_verb = NotificationVerbs.ANNOUNCEMENT
+ title = MessageTemplates.announcement(author_name)
+ max_length = 150
+ is_announcement = True
+ else:
+ notification_verb = NotificationVerbs.NEW_MESSAGE
+ if chat.type == 'private':
+ title = MessageTemplates.private_message(author_name)
+ else:
+ title = MessageTemplates.group_message(author_name, get_chat_name(chat))
+ max_length = 100
+ is_announcement = False
+
+ for recipient in participants_list:
+ # Пропускаем тех, кому уже отправили уведомление
+ if recipient.id in notified_user_ids:
+ continue
+
+ # Проверяем настройки уведомлений
+ if not notification_settings.get(recipient.id, True):
+ continue
+
+ metadata = {
+ 'chat_id': chat.id,
+ 'chat_name': get_chat_name(chat),
+ 'message_id': message.id,
+ 'author_id': author.id,
+ }
+ if is_announcement:
+ metadata['is_announcement'] = True
+
+ notify.send(
+ sender=author,
+ recipient=recipient,
+ verb=notification_verb,
+ action_object=message,
+ description=truncate_message(content, max_length),
+ action_url=ActionURLs.MESSAGES,
+ data={**metadata, 'title': title},
+ )
+
+
+def notify_chat_added(chat, new_users, added_by=None):
+ """
+ Отправляет уведомления пользователям о добавлении в чат.
+
+ Args:
+ chat: Объект Chat
+ new_users: Список/QuerySet новых пользователей
+ added_by: Пользователь, который добавил (опционально)
+ """
+ # ОПТИМИЗАЦИЯ: Загружаем настройки одним запросом
+ notification_settings = get_users_with_notifications_enabled(chat, new_users)
+
+ # Создаем уведомления для новых участников
+ for user in new_users:
+ # Не отправляем уведомление тому, кто сам себя добавил
+ if added_by and user.id == added_by.id:
+ continue
+
+ # Проверяем настройки уведомлений
+ if not notification_settings.get(user.id, True):
+ continue
+
+ notify.send(
+ sender=added_by,
+ recipient=user,
+ verb=NotificationVerbs.ADDED_TO_CHAT,
+ action_object=chat,
+ description=MessageTemplates.added_to_chat(get_chat_name(chat)),
+ action_url=ActionURLs.MESSAGES,
+ data={
+ 'title': MessageTemplates.added_to_chat_title(),
+ 'chat_id': chat.id,
+ 'chat_name': get_chat_name(chat),
+ 'added_by_id': added_by.id if added_by else None,
+ }
+ )
diff --git a/backend/communications/notifications/signals.py b/backend/communications/notifications/signals.py
new file mode 100644
index 00000000..034766a0
--- /dev/null
+++ b/backend/communications/notifications/signals.py
@@ -0,0 +1,62 @@
+"""
+Django signals для автоматической генерации уведомлений в модуле Communications.
+
+Обрабатывает события:
+- post_save для Message - новое сообщение (с упоминаниями, ответами)
+- m2m_changed для Chat.participants - добавление пользователей в чат
+"""
+
+from django.contrib.auth import get_user_model
+from django.db.models.signals import post_save, m2m_changed
+from django.dispatch import receiver
+
+from ..models import Message, Chat
+from .handlers import notify_new_message, notify_chat_added
+
+Employee = get_user_model()
+
+
+@receiver(post_save, sender=Message)
+def create_message_notifications(sender, instance, created, **kwargs):
+ """
+ Создает уведомления при создании нового сообщения.
+
+ Обрабатывает:
+ 1. Новое сообщение в чате (для всех участников кроме автора)
+ 2. Упоминания (@username) в тексте
+ 3. Ответ на сообщение (reply_to)
+
+ ОПТИМИЗАЦИЯ: Уведомления отправляются через универсальную систему
+ channels.py → Celery → WebSocket/Email/Push
+ """
+ if not created or instance.is_system or instance.is_deleted:
+ return
+
+ # Отправляем через универсальную систему
+ notify_new_message(instance)
+
+
+@receiver(m2m_changed, sender=Chat.participants.through)
+def create_chat_added_notifications(sender, instance, action, pk_set, **kwargs):
+ """
+ Создает уведомления когда пользователя добавляют в чат.
+
+ Args:
+ instance: Chat объект
+ action: Тип изменения ('post_add', 'post_remove', etc.)
+ pk_set: Набор ID добавленных/удаленных пользователей
+ """
+ if action != 'post_add':
+ return
+
+ chat = instance
+
+ # Получаем информацию о том, кто добавил (если доступно)
+ # Это может быть последний изменивший или создатель чата
+ added_by = getattr(chat, 'created_by', None) or chat.participants.first()
+
+ # ОПТИМИЗАЦИЯ: Получаем всех новых пользователей одним запросом
+ new_users = Employee.objects.filter(id__in=pk_set)
+
+ # Отправляем уведомления через handlers
+ notify_chat_added(chat, new_users, added_by=added_by)
diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py
index 05540eab..8651324a 100644
--- a/backend/communications/serialization.py
+++ b/backend/communications/serialization.py
@@ -34,7 +34,7 @@ def serialize_message(m) -> dict:
"content": m.content,
"author_id": author.id if author else None,
"author_name": author_name,
- "author_url": reverse("employees:employee_detail", args=[author.id]) if author else "",
+ "author_url": f"/api/v1/employees/{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),
diff --git a/backend/communications/tests.py b/backend/communications/tests.py
deleted file mode 100644
index 7ce503c2..00000000
--- a/backend/communications/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/backend/communications/urls.py b/backend/communications/urls.py
deleted file mode 100644
index de0cc951..00000000
--- a/backend/communications/urls.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# backend/communications/urls.py
-
-from django.urls import path
-from django.views.generic import TemplateView
-
-from . import views
-
-app_name = "communications"
-
-urlpatterns = [
- # UI Views
- path("chats/", views.ChatListView.as_view(), name="chat_list"),
- path(
- "chats//",
- views.ChatDetailView.as_view(),
- name="chat_detail"
- ),
- path(
- "test/websocket-events/",
- TemplateView.as_view(template_name="test_websocket_events.html"),
- name="test_websocket_events"
- ),
- path(
- "chats/start//",
- views.start_private_chat,
- name="start_private_chat"
- ),
- path(
- "chats//mark-read/",
- views.chat_mark_read,
- name="chat_mark_read"
- ),
- path(
- "start/private//",
- views.start_private_chat,
- name="start_private_chat"
- ),
-]
diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml
deleted file mode 100644
index 1964acd3..00000000
--- a/backend/docker-compose.test.yml
+++ /dev/null
@@ -1,57 +0,0 @@
-version: '3.8'
-
-services:
- # OpenLDAP сервер для тестирования
- test-ldap:
- image: osixia/openldap:1.5.0
- container_name: eusrr-test-ldap
- hostname: ldap.test.local
- environment:
- # Базовая конфигурация
- LDAP_ORGANISATION: "EUSRR Test"
- LDAP_DOMAIN: "test.local"
- LDAP_BASE_DN: "dc=test,dc=local"
- LDAP_ADMIN_PASSWORD: "test_change-me-redacted-secret"
- LDAP_CONFIG_PASSWORD: "test_config_password"
- LDAP_READONLY_USER: "false"
-
- # Настройки для совместимости с вашим кодом
- LDAP_RFC2307BIS_SCHEMA: "true"
- LDAP_BACKEND: "mdb"
- LDAP_TLS: "false"
- LDAP_TLS_VERIFY_CLIENT: "never"
-
- # Логирование для отладки
- LDAP_LOG_LEVEL: "256"
- ports:
- - "10389:389" # Используем порт 10389 чтобы не конфликтовать с prod
- - "10636:636" # SSL порт (если понадобится)
- volumes:
- - ./ldap_test_data:/var/lib/ldap
- - ./ldap_test_config:/etc/ldap/slapd.d
- - ./tests/ldap_fixtures:/container/service/slapd/assets/config/bootstrap/ldif/custom
- networks:
- - test-network
- command: --copy-service --loglevel debug
-
- # phpLDAPadmin - веб-интерфейс для управления (опционально)
- ldap-admin:
- image: osixia/phpldapadmin:0.9.0
- container_name: eusrr-ldap-admin
- environment:
- PHPLDAPADMIN_LDAP_HOSTS: "test-ldap"
- PHPLDAPADMIN_HTTPS: "false"
- ports:
- - "8090:80"
- depends_on:
- - test-ldap
- networks:
- - test-network
-
-networks:
- test-network:
- driver: bridge
-
-volumes:
- ldap_test_data:
- ldap_test_config:
diff --git a/backend/documents/apps.py b/backend/documents/apps.py
index 70360190..182d2a07 100644
--- a/backend/documents/apps.py
+++ b/backend/documents/apps.py
@@ -6,6 +6,6 @@ class DocumentsConfig(AppConfig):
name = "documents"
def ready(self):
- # импортируем сигналы, чтобы они подхватились
- import documents.notification_signals # noqa
+ # Модуль уведомлений (signals регистрируются автоматически)
+ import documents.notifications # noqa
import documents.rules # django-rules: регистрация предикатов и правил доступа
diff --git a/backend/documents/notification_signals.py b/backend/documents/notification_signals.py
deleted file mode 100644
index e168e084..00000000
--- a/backend/documents/notification_signals.py
+++ /dev/null
@@ -1,573 +0,0 @@
-"""
-Signals для автоматической генерации уведомлений в модуле Documents.
-
-Обрабатывает события:
-- Новый документ на ознакомление
-- Все сотрудники ознакомились
-- Напоминание о необходимости ознакомления (через Celery task)
-"""
-
-import logging
-
-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, DocumentComment
-from notifications.signals import notify
-
-logger = logging.getLogger(__name__)
-Employee = get_user_model()
-
-
-@receiver(post_save, sender=Document)
-def create_document_notification(sender, instance, created, **kwargs):
- """
- Создает уведомления при создании нового документа асинхронно.
-
- Уведомления отправляются:
- - Всем активным сотрудникам (если sent_to_all=True)
- - Сотрудникам выбранных отделов (через m2m_changed signal)
- - Выбранным получателям (через m2m_changed signal)
- """
- logger.info(
- f"[notification_signals] post_save Document id={instance.pk} "
- f"created={created} sent_to_all={instance.sent_to_all}"
- )
-
- if not created:
- return
-
- document = instance
-
- # Если документ отправляется всем - создаем уведомления напрямую
- # channels.py автоматически отправит через Celery
- if document.sent_to_all:
- logger.info(
- f"[notification_signals] Creating notifications for doc={document.pk} (sent_to_all=True)"
- )
- notify_all_employees(document)
- else:
- logger.info(
- f"[notification_signals] Skipping notifications (sent_to_all=False), "
- f"waiting for m2m_changed"
- )
-
-
-@receiver(m2m_changed, sender=Document.recipients.through)
-def notify_specific_recipients(sender, instance, action, pk_set, **kwargs):
- """
- Уведомляет конкретных получателей при добавлении в список.
- Использует быструю обработку для большого количества получателей.
- """
- logger.info(
- f"[notification_signals] m2m_changed Document id={instance.pk} "
- f"action={action} pk_set={pk_set} sent_to_all={instance.sent_to_all}"
- )
-
- if action != 'post_add':
- logger.info(
- f"[notification_signals] Skipping (action={action} != 'post_add')"
- )
- return
-
- document = instance
-
- # Не отправляем индивидуальные уведомления если документ для всех
- if document.sent_to_all:
- logger.info(
- f"[notification_signals] Skipping (sent_to_all=True)"
- )
- return
-
- recipient_count = len(pk_set or [])
- logger.info(
- f"[notification_signals] Processing {recipient_count} recipients"
- )
-
- # Определяем, это массовая рассылка или нет
- from django.conf import settings
- BULK_THRESHOLD = getattr(settings, 'NOTIFICATION_BULK_THRESHOLD', 10)
- is_bulk = recipient_count >= BULK_THRESHOLD
-
- if is_bulk:
- logger.info(
- f"[notification_signals] Bulk mode activated "
- f"({recipient_count} >= {BULK_THRESHOLD})"
- )
-
- # Уведомляем новых получателей
- created_count = 0
- for user_id in pk_set:
- try:
- user = Employee.objects.get(id=user_id)
- logger.debug(
- f"[notification_signals] Creating notification for user={user_id}"
- )
- uploader_name = (
- document.uploaded_by.get_full_name()
- if document.uploaded_by
- else 'Администратор'
- )
- notify.send(
- sender=document.uploaded_by,
- recipient=user,
- verb='document_ready',
- action_object=document,
- description=(
- f'{uploader_name} загрузил документ "{document.title}". '
- f'Требуется ознакомление.'
- ),
- action_url='/documents',
- data={
- 'title': 'Новый документ на ознакомление',
- 'document_id': document.id,
- 'uploaded_by_id': (
- document.uploaded_by.id if document.uploaded_by else None
- ),
- 'sent_to_all': document.sent_to_all,
- },
- )
- created_count += 1
- except Employee.DoesNotExist:
- logger.warning(f"[notification_signals] User {user_id} not found, skipping")
- except Exception as e:
- logger.error(f"[notification_signals] Error creating notification for user {user_id}: {e}")
-
- logger.info(f"[notification_signals] Created {created_count} notifications")
-
-
-@receiver(m2m_changed, sender=Document.departments.through)
-def notify_department_employees(sender, instance, action, pk_set, **kwargs):
- """
- Уведомляет всех сотрудников выбранных отделов при добавлении отделов.
- Уведомления получат ВСЕ текущие и будущие сотрудники этих отделов.
- """
- logger.info(
- f"[notification_signals] m2m_changed Document.departments "
- f"id={instance.pk} action={action} pk_set={pk_set}"
- )
-
- if action != 'post_add':
- logger.info(
- f"[notification_signals] Skipping (action={action} != 'post_add')"
- )
- return
-
- document = instance
-
- # Не отправляем если документ для всех
- if document.sent_to_all:
- logger.info(
- "[notification_signals] Skipping (sent_to_all=True)"
- )
- return
-
- if not pk_set:
- logger.warning("[notification_signals] No departments provided")
- return
-
- # Получаем всех активных сотрудников выбранных отделов
- from employees.models import Department
-
- all_employees = set()
- for dept_id in pk_set:
- try:
- department = Department.objects.get(id=dept_id)
- dept_employees = department.active_employees
- logger.info(
- f"[notification_signals] Department '{department.name}' "
- f"has {len(dept_employees)} active employees: "
- f"{[e.id for e in dept_employees]}"
- )
- all_employees.update(dept_employees)
- logger.info(
- f"[notification_signals] After update, all_employees has: "
- f"{[e.id for e in all_employees]}"
- )
- except Department.DoesNotExist:
- logger.warning(
- f"[notification_signals] Department {dept_id} not found"
- )
- continue
-
- # Исключаем загрузившего
- if document.uploaded_by and document.uploaded_by in all_employees:
- all_employees.remove(document.uploaded_by)
-
- recipient_count = len(all_employees)
- logger.info(
- f"[notification_signals] Processing {recipient_count} employees "
- f"from {len(pk_set)} departments"
- )
-
- # Определяем режим рассылки
- from django.conf import settings
- BULK_THRESHOLD = getattr(settings, 'NOTIFICATION_BULK_THRESHOLD', 10)
- is_bulk = recipient_count >= BULK_THRESHOLD
-
- if is_bulk:
- logger.info(
- f"[notification_signals] Bulk mode activated "
- f"({recipient_count} >= {BULK_THRESHOLD})"
- )
-
- # Создаём уведомления
- created_count = 0
- uploader_name = (
- document.uploaded_by.get_full_name()
- if document.uploaded_by
- else 'Администратор'
- )
- for employee in all_employees:
- try:
- notify.send(
- sender=document.uploaded_by,
- recipient=employee,
- verb='document_ready',
- action_object=document,
- description=(
- f'{uploader_name} загрузил документ "{document.title}". '
- f'Требуется ознакомление.'
- ),
- action_url='/documents',
- data={
- 'title': 'Новый документ на ознакомление',
- 'document_id': document.id,
- 'uploaded_by_id': (
- document.uploaded_by.id if document.uploaded_by else None
- ),
- 'sent_to_all': document.sent_to_all,
- },
- )
- created_count += 1
- except Exception as e:
- logger.error(f"[notification_signals] Error creating notification for employee {employee.id}: {e}")
-
- logger.info(f"[notification_signals] Created {created_count} notifications")
-
-
-@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:
- # Считаем получателей из recipients + departments
- recipients_count = document.recipients.count()
-
- # Добавляем сотрудников из отделов
- from employees.models import Department
- department_employees = set()
- for department in document.departments.all():
- department_employees.update(department.active_employees)
-
- # Исключаем дубликаты с recipients
- total_recipients = recipients_count + len(department_employees)
-
- # Проверяем количество ознакомившихся
- acknowledged_count = document.acknowledgements.count()
-
- # Если все ознакомились - уведомляем загрузившего
- if (acknowledged_count >= total_recipients and
- total_recipients > 0 and
- document.uploaded_by):
-
- notify.send(
- sender=None,
- recipient=document.uploaded_by,
- verb='document_signed_all',
- action_object=document,
- description=(
- f'Все сотрудники ознакомились с документом '
- f'"{document.title}"'
- ),
- action_url='/documents',
- data={
- 'title': 'Все ознакомились с документом',
- 'document_id': document.id,
- 'total_recipients': total_recipients,
- 'acknowledged_count': acknowledged_count,
- }
- )
-
-
-# ===== Вспомогательные функции =====
-
-def notify_all_employees(document):
- """
- Отправляет уведомление о новом документе всем активным сотрудникам.
- Веб-уведомления отправляются сразу, email/Telegram - асинхронно.
- """
- logger.info(
- f"[notification_signals] notify_all_employees doc={document.id}"
- )
-
- active_employees = Employee.objects.filter(is_active=True)
-
- # Исключаем загрузившего документ
- if document.uploaded_by:
- active_employees = active_employees.exclude(
- id=document.uploaded_by.id
- )
-
- count = active_employees.count()
- logger.info(
- f"[notification_signals] Creating {count} notifications "
- f"(web immediately, email/telegram deferred)"
- )
-
- # Создаём уведомления с ЧАСТИЧНОЙ отправкой
- created_count = 0
- uploader_name = (
- document.uploaded_by.get_full_name()
- if document.uploaded_by
- else 'Администратор'
- )
- for employee in active_employees:
- try:
- notify.send(
- sender=document.uploaded_by,
- recipient=employee,
- verb='document_ready',
- action_object=document,
- description=(
- f'{uploader_name} загрузил документ "{document.title}". '
- f'Требуется ознакомление.'
- ),
- action_url='/documents',
- data={
- 'title': 'Новый документ на ознакомление',
- 'document_id': document.id,
- 'uploaded_by_id': (
- document.uploaded_by.id if document.uploaded_by else None
- ),
- 'sent_to_all': document.sent_to_all,
- },
- )
- created_count += 1
- except Exception as e:
- logger.error(
- f"Error creating notification for user {employee.id}: {e}"
- )
-
- logger.info(
- f"[notification_signals] Created {created_count}/{count} notifications "
- f"for doc={document.id}. Web sent immediately, email/telegram pending."
- )
-
- # Запускаем асинхронную отправку email/telegram
- try:
- from django.core.management import call_command
- import threading
-
- def send_pending():
- try:
- call_command('send_pending_notifications', '--batch-size=100')
- except Exception as e:
- logger.error(f"Error in background send: {e}")
-
- # Запускаем в отдельном потоке
- thread = threading.Thread(target=send_pending, daemon=True)
- thread.start()
- logger.info("[notification_signals] Started background email/telegram send")
- except Exception as e:
- logger.error(f"Error starting background send: {e}")
-def create_document_ready_notification(document, recipient, send_immediately=True):
- """
- Создает уведомление о новом документе для конкретного получателя.
- """
- logger.info(
- f"[notification_signals] create_document_ready_notification "
- f"doc={document.id} recipient={recipient.id}"
- )
-
- uploader_name = (
- document.uploaded_by.get_full_name()
- if document.uploaded_by
- else 'Администратор'
- )
-
- notify.send(
- sender=document.uploaded_by,
- recipient=recipient,
- verb='document_ready',
- action_object=document,
- description=(
- f'{uploader_name} загрузил документ "{document.title}". '
- f'Требуется ознакомление.'
- ),
- action_url='/documents',
- data={
- 'title': 'Новый документ на ознакомление',
- 'document_id': document.id,
- 'uploaded_by_id': (
- document.uploaded_by.id if document.uploaded_by else None
- ),
- 'sent_to_all': document.sent_to_all,
- },
- )
-
-
-# =============================================================================
-# НОВЫЕ СИГНАЛЫ ДЛЯ КОММЕНТАРИЕВ И СВЯЗАННЫХ ДОКУМЕНТОВ
-# =============================================================================
-
-@receiver(post_save, sender=DocumentComment)
-def notify_on_new_comment(sender, instance, created, **kwargs):
- """
- Отправка уведомлений при создании нового комментария.
-
- - Если это ответ на комментарий (parent != None): уведомить автора родительского комментария
- - Если это обычный комментарий: уведомить автора документа (если он не автор комментария)
- """
- if not created:
- return
-
- comment = instance
- document = comment.document
- author = comment.author
-
- logger.info(
- f"[notification_signals] New comment on document={document.pk} "
- f"by user={author.pk} parent={comment.parent_id}"
- )
-
- # Если это ответ на комментарий
- if comment.parent:
- parent_author = comment.parent.author
-
- # Не уведомляем самого себя
- if parent_author.id == author.id:
- logger.info(
- f"[notification_signals] Skipping self-reply notification"
- )
- return
-
- logger.info(
- f"[notification_signals] Notifying parent author={parent_author.pk} "
- f"about reply"
- )
-
- notify.send(
- sender=author,
- recipient=parent_author,
- verb='document_comment_reply',
- action_object=comment,
- description=(
- f'{author.get_full_name() or author.username} ответил на ваш '
- f'комментарий к документу "{document.title}"'
- ),
- action_url='/documents',
- data={
- 'title': 'Ответ на ваш комментарий',
- 'document_id': document.id,
- 'comment_id': comment.id,
- 'parent_comment_id': comment.parent_id,
- 'author_id': author.id,
- },
- )
- else:
- # Обычный комментарий - уведомляем автора документа
- if not document.uploaded_by:
- logger.info(
- f"[notification_signals] Document has no author, skipping"
- )
- return
-
- doc_author = document.uploaded_by
-
- # Не уведомляем самого себя
- if doc_author.id == author.id:
- logger.info(
- f"[notification_signals] Skipping self-comment notification"
- )
- return
-
- logger.info(
- f"[notification_signals] Notifying document author={doc_author.pk} "
- f"about new comment"
- )
-
- notify.send(
- sender=author,
- recipient=doc_author,
- verb='document_comment',
- action_object=comment,
- description=(
- f'{author.get_full_name() or author.username} оставил комментарий '
- f'к вашему документу "{document.title}"'
- ),
- action_url='/documents',
- data={
- 'title': 'Новый комментарий к документу',
- 'document_id': document.id,
- 'comment_id': comment.id,
- 'author_id': author.id,
- },
- )
-
-
-@receiver(m2m_changed, sender=Document.related_documents.through)
-def notify_on_related_document_added(sender, instance, action, pk_set, **kwargs):
- """
- Уведомление при добавлении связанного документа.
- Уведомляем автора связанного документа (если это не тот же пользователь).
- """
- if action != 'post_add':
- return
-
- if not pk_set:
- return
-
- main_document = instance
-
- logger.info(
- f"[notification_signals] Related documents added to doc={main_document.pk} "
- f"related_ids={pk_set}"
- )
-
- # Получаем связанные документы
- related_documents = Document.objects.filter(pk__in=pk_set).select_related('uploaded_by')
-
- for related_doc in related_documents:
- # Если у связанного документа есть автор
- if not related_doc.uploaded_by:
- continue
-
- # Не уведомляем если авторы совпадают
- if (main_document.uploaded_by and
- related_doc.uploaded_by.id == main_document.uploaded_by.id):
- continue
-
- logger.info(
- f"[notification_signals] Notifying author={related_doc.uploaded_by.pk} "
- f"that their document was linked"
- )
-
- notify.send(
- sender=main_document.uploaded_by,
- recipient=related_doc.uploaded_by,
- verb='document_related',
- action_object=main_document,
- description=(
- f'Ваш документ "{related_doc.title}" связан '
- f'с документом "{main_document.title}"'
- ),
- action_url='/documents',
- data={
- 'title': 'Документ связан с другим',
- 'document_id': main_document.id,
- 'related_document_id': related_doc.id,
- },
- )
diff --git a/backend/documents/notifications/__init__.py b/backend/documents/notifications/__init__.py
new file mode 100644
index 00000000..586a3a68
--- /dev/null
+++ b/backend/documents/notifications/__init__.py
@@ -0,0 +1,35 @@
+"""
+Модуль уведомлений для Documents приложения.
+
+Публичный API:
+ - NotificationVerbs - константы глаголов уведомлений
+ - MessageTemplates - шаблоны сообщений
+ - ActionURLs - генераторы URL-адресов
+ - notify_document_ready - функция для ручного уведомления о документе
+
+Использование:
+ from documents.notifications import notify_document_ready
+
+ # В коде:
+ notify_document_ready(document, user)
+"""
+
+from .config import (
+ NotificationVerbs,
+ MessageTemplates,
+ ActionURLs,
+ get_uploader_name,
+)
+from .handlers import notify_document_ready
+
+# Импортируем handlers и signals для регистрации
+from . import handlers # noqa: F401
+from . import signals # noqa: F401
+
+__all__ = [
+ 'NotificationVerbs',
+ 'MessageTemplates',
+ 'ActionURLs',
+ 'notify_document_ready',
+ 'get_uploader_name',
+]
diff --git a/backend/documents/notifications/config.py b/backend/documents/notifications/config.py
new file mode 100644
index 00000000..dc163616
--- /dev/null
+++ b/backend/documents/notifications/config.py
@@ -0,0 +1,114 @@
+"""
+Конфигурация уведомлений для модуля Documents.
+
+Содержит:
+- NotificationVerbs - глаголы для уведомлений
+- MessageTemplates - шаблоны сообщений
+- ActionURLs - URL-адреса действий
+"""
+
+from django.conf import settings
+
+
+class NotificationVerbs:
+ """Глаголы (verbs) для уведомлений documents."""
+
+ DOCUMENT_READY = 'document_ready'
+ DOCUMENT_SIGNED_ALL = 'document_signed_all'
+ DOCUMENT_COMMENT = 'document_comment'
+ DOCUMENT_COMMENT_REPLY = 'document_comment_reply'
+
+
+class MessageTemplates:
+ """Шаблоны сообщений для уведомлений documents."""
+
+ @staticmethod
+ def document_ready(uploader_name: str, document_title: str) -> str:
+ """Шаблон для нового документа на ознакомление."""
+ return (
+ f'{uploader_name} загрузил документ "{document_title}". '
+ f'Требуется ознакомление.'
+ )
+
+ @staticmethod
+ def document_ready_title() -> str:
+ """Заголовок для нового документа."""
+ return 'Новый документ на ознакомление'
+
+ @staticmethod
+ def all_acknowledged(document_title: str) -> str:
+ """Шаблон для завершения ознакомления всеми."""
+ return f'Все сотрудники ознакомились с документом "{document_title}"'
+
+ @staticmethod
+ def all_acknowledged_title() -> str:
+ """Заголовок для завершения ознакомления."""
+ return 'Все ознакомились с документом'
+
+ @staticmethod
+ def comment(author_name: str, document_title: str) -> str:
+ """Шаблон для нового комментария к документу."""
+ return (
+ f'{author_name} оставил комментарий '
+ f'к вашему документу "{document_title}"'
+ )
+
+ @staticmethod
+ def comment_title() -> str:
+ """Заголовок для комментария к документу."""
+ return 'Новый комментарий к документу'
+
+ @staticmethod
+ def comment_reply(author_name: str, document_title: str) -> str:
+ """Шаблон для ответа на комментарий."""
+ return (
+ f'{author_name} ответил на ваш '
+ f'комментарий к документу "{document_title}"'
+ )
+
+ @staticmethod
+ def comment_reply_title() -> str:
+ """Заголовок для ответа на комментарий."""
+ return 'Ответ на ваш комментарий'
+
+
+class ActionURLs:
+ """URL-адреса для действий с документами."""
+
+ DOCUMENTS = '/documents'
+
+ @staticmethod
+ def document_detail(document_id: int) -> str:
+ """
+ Возвращает URL-адрес документа.
+
+ TODO: реализовать прямые ссылки на конкретные документы
+ """
+ return f'/documents?id={document_id}'
+
+
+# ===== Константы =====
+
+def get_bulk_threshold() -> int:
+ """
+ Возвращает порог для определения массовой рассылки.
+
+ Returns:
+ int: Количество получателей, начиная с которого используется bulk режим
+ """
+ return getattr(settings, 'NOTIFICATION_BULK_THRESHOLD', 10)
+
+
+def get_uploader_name(uploaded_by) -> str:
+ """
+ Возвращает имя загрузившего документ пользователя.
+
+ Args:
+ uploaded_by: Объект Employee или None
+
+ Returns:
+ str: Полное имя или 'Администратор'
+ """
+ if uploaded_by:
+ return uploaded_by.get_full_name() or uploaded_by.username
+ return 'Администратор'
diff --git a/backend/documents/notifications/handlers.py b/backend/documents/notifications/handlers.py
new file mode 100644
index 00000000..86e1513b
--- /dev/null
+++ b/backend/documents/notifications/handlers.py
@@ -0,0 +1,404 @@
+"""
+Бизнес-логика отправки уведомлений для модуля Documents.
+
+Функции:
+- notify_document_ready - уведомление о новом документе
+- notify_all_employees - уведомление всем сотрудникам
+- notify_specific_users - уведомление конкретным пользователям
+- notify_department_employees - уведомление сотрудникам отделов
+- notify_all_acknowledged - уведомление о завершении ознакомления
+- notify_document_comment - уведомление о комментарии к документу
+"""
+
+import logging
+
+from django.contrib.auth import get_user_model
+from notifications.signals import notify
+
+from .config import (
+ NotificationVerbs,
+ MessageTemplates,
+ ActionURLs,
+ get_bulk_threshold,
+ get_uploader_name,
+)
+
+logger = logging.getLogger(__name__)
+Employee = get_user_model()
+
+
+def notify_document_ready(document, recipient):
+ """
+ Отправляет уведомление конкретному получателю о новом документе.
+
+ Args:
+ document: Объект Document
+ recipient: Объект Employee
+ """
+ logger.info(
+ f"[handlers] create_document_ready_notification "
+ f"doc={document.id} recipient={recipient.id}"
+ )
+
+ uploader_name = get_uploader_name(document.uploaded_by)
+
+ notify.send(
+ sender=document.uploaded_by,
+ recipient=recipient,
+ verb=NotificationVerbs.DOCUMENT_READY,
+ action_object=document,
+ description=MessageTemplates.document_ready(uploader_name, document.title),
+ action_url=ActionURLs.DOCUMENTS,
+ data={
+ 'title': MessageTemplates.document_ready_title(),
+ 'document_id': document.id,
+ 'uploaded_by_id': document.uploaded_by.id if document.uploaded_by else None,
+ 'sent_to_all': document.sent_to_all,
+ },
+ )
+
+
+def notify_all_employees(document):
+ """
+ Отправляет уведомление о новом документе всем активным сотрудникам.
+
+ Args:
+ document: Объект Document
+ """
+ logger.info(
+ f"[handlers] notify_all_employees doc={document.id}"
+ )
+
+ active_employees = Employee.objects.filter(is_active=True)
+
+ # Исключаем загрузившего документ
+ if document.uploaded_by:
+ active_employees = active_employees.exclude(id=document.uploaded_by.id)
+
+ count = active_employees.count()
+ logger.info(
+ f"[handlers] Creating {count} notifications "
+ f"(web immediately, email/telegram deferred)"
+ )
+
+ # Создаём уведомления
+ created_count = 0
+ uploader_name = get_uploader_name(document.uploaded_by)
+
+ for employee in active_employees:
+ try:
+ notify.send(
+ sender=document.uploaded_by,
+ recipient=employee,
+ verb=NotificationVerbs.DOCUMENT_READY,
+ action_object=document,
+ description=MessageTemplates.document_ready(uploader_name, document.title),
+ action_url=ActionURLs.DOCUMENTS,
+ data={
+ 'title': MessageTemplates.document_ready_title(),
+ 'document_id': document.id,
+ 'uploaded_by_id': (
+ document.uploaded_by.id if document.uploaded_by else None
+ ),
+ 'sent_to_all': document.sent_to_all,
+ },
+ )
+ created_count += 1
+ except Exception as e:
+ logger.error(
+ f"[handlers] Error creating notification for user {employee.id}: {e}"
+ )
+
+ logger.info(
+ f"[handlers] Created {created_count}/{count} notifications "
+ f"for doc={document.id}. Web sent immediately, email/telegram pending."
+ )
+
+ # Запускаем асинхронную отправку email/telegram
+ try:
+ from django.core.management import call_command
+ import threading
+
+ def send_pending():
+ try:
+ call_command('send_pending_notifications', '--batch-size=100')
+ except Exception as e:
+ logger.error(f"[handlers] Error in background send: {e}")
+
+ # Запускаем в отдельном потоке
+ thread = threading.Thread(target=send_pending, daemon=True)
+ thread.start()
+ logger.info("[handlers] Started background email/telegram send")
+ except Exception as e:
+ logger.error(f"[handlers] Error starting background send: {e}")
+
+
+def notify_specific_users(document, user_ids):
+ """
+ Уведомляет конкретных получателей о документе.
+
+ Args:
+ document: Объект Document
+ user_ids: Набор ID пользователей
+ """
+ recipient_count = len(user_ids or [])
+ logger.info(
+ f"[handlers] Processing {recipient_count} specific recipients"
+ )
+
+ # Определяем, это массовая рассылка или нет
+ is_bulk = recipient_count >= get_bulk_threshold()
+
+ if is_bulk:
+ logger.info(
+ f"[handlers] Bulk mode activated "
+ f"({recipient_count} >= {get_bulk_threshold()})"
+ )
+
+ # Уведомляем получателей
+ created_count = 0
+ uploader_name = get_uploader_name(document.uploaded_by)
+
+ for user_id in user_ids:
+ try:
+ user = Employee.objects.get(id=user_id)
+ logger.debug(
+ f"[handlers] Creating notification for user={user_id}"
+ )
+ notify.send(
+ sender=document.uploaded_by,
+ recipient=user,
+ verb=NotificationVerbs.DOCUMENT_READY,
+ action_object=document,
+ description=MessageTemplates.document_ready(uploader_name, document.title),
+ action_url=ActionURLs.DOCUMENTS,
+ data={
+ 'title': MessageTemplates.document_ready_title(),
+ 'document_id': document.id,
+ 'uploaded_by_id': (
+ document.uploaded_by.id if document.uploaded_by else None
+ ),
+ 'sent_to_all': document.sent_to_all,
+ },
+ )
+ created_count += 1
+ except Employee.DoesNotExist:
+ logger.warning(f"[handlers] User {user_id} not found, skipping")
+ except Exception as e:
+ logger.error(f"[handlers] Error creating notification for user {user_id}: {e}")
+
+ logger.info(f"[handlers] Created {created_count} notifications")
+
+
+def notify_department_employees(document, department_ids):
+ """
+ Уведомляет всех сотрудников выбранных отделов о документе.
+
+ Args:
+ document: Объект Document
+ department_ids: Набор ID отделов
+ """
+ logger.info(
+ f"[handlers] Processing {len(department_ids)} departments"
+ )
+
+ if not department_ids:
+ logger.warning("[handlers] No departments provided")
+ return
+
+ # Получаем всех активных сотрудников выбранных отделов
+ from employees.models import Department
+
+ all_employees = set()
+ for dept_id in department_ids:
+ try:
+ department = Department.objects.get(id=dept_id)
+ dept_employees = department.active_employees
+ logger.info(
+ f"[handlers] Department '{department.name}' "
+ f"has {len(dept_employees)} active employees: "
+ f"{[e.id for e in dept_employees]}"
+ )
+ all_employees.update(dept_employees)
+ logger.info(
+ f"[handlers] After update, all_employees has: "
+ f"{[e.id for e in all_employees]}"
+ )
+ except Department.DoesNotExist:
+ logger.warning(
+ f"[handlers] Department {dept_id} not found"
+ )
+ continue
+
+ # Исключаем загрузившего
+ if document.uploaded_by and document.uploaded_by in all_employees:
+ all_employees.remove(document.uploaded_by)
+
+ recipient_count = len(all_employees)
+ logger.info(
+ f"[handlers] Processing {recipient_count} employees "
+ f"from {len(department_ids)} departments"
+ )
+
+ # Определяем режим рассылки
+ is_bulk = recipient_count >= get_bulk_threshold()
+
+ if is_bulk:
+ logger.info(
+ f"[handlers] Bulk mode activated "
+ f"({recipient_count} >= {get_bulk_threshold()})"
+ )
+
+ # Создаём уведомления
+ created_count = 0
+ uploader_name = get_uploader_name(document.uploaded_by)
+
+ for employee in all_employees:
+ try:
+ notify.send(
+ sender=document.uploaded_by,
+ recipient=employee,
+ verb=NotificationVerbs.DOCUMENT_READY,
+ action_object=document,
+ description=MessageTemplates.document_ready(uploader_name, document.title),
+ action_url=ActionURLs.DOCUMENTS,
+ data={
+ 'title': MessageTemplates.document_ready_title(),
+ 'document_id': document.id,
+ 'uploaded_by_id': (
+ document.uploaded_by.id if document.uploaded_by else None
+ ),
+ 'sent_to_all': document.sent_to_all,
+ },
+ )
+ created_count += 1
+ except Exception as e:
+ logger.error(f"[handlers] Error creating notification for employee {employee.id}: {e}")
+
+ logger.info(f"[handlers] Created {created_count} notifications")
+
+
+def notify_all_acknowledged(document, total_recipients, acknowledged_count):
+ """
+ Уведомляет загрузившего о том, что все ознакомились с документом.
+
+ Args:
+ document: Объект Document
+ total_recipients: Общее количество получателей
+ acknowledged_count: Количество ознакомившихся
+ """
+ if not document.uploaded_by:
+ logger.info(
+ "[handlers] Document has no uploader, skipping acknowledgement notification"
+ )
+ return
+
+ logger.info(
+ f"[handlers] Notifying uploader about full acknowledgement "
+ f"({acknowledged_count}/{total_recipients})"
+ )
+
+ notify.send(
+ sender=None,
+ recipient=document.uploaded_by,
+ verb=NotificationVerbs.DOCUMENT_SIGNED_ALL,
+ action_object=document,
+ description=MessageTemplates.all_acknowledged(document.title),
+ action_url=ActionURLs.DOCUMENTS,
+ data={
+ 'title': MessageTemplates.all_acknowledged_title(),
+ 'document_id': document.id,
+ 'total_recipients': total_recipients,
+ 'acknowledged_count': acknowledged_count,
+ }
+ )
+
+
+def notify_document_comment(document, comment):
+ """
+ Уведомляет автора документа о новом комментарии.
+
+ Args:
+ document: Объект Document
+ comment: Объект DocumentComment
+ """
+ author = comment.author
+
+ if not document.uploaded_by:
+ logger.info(
+ "[handlers] Document has no author, skipping"
+ )
+ return
+
+ doc_author = document.uploaded_by
+
+ # Не уведомляем самого себя
+ if doc_author.id == author.id:
+ logger.info(
+ "[handlers] Skipping self-comment notification"
+ )
+ return
+
+ logger.info(
+ f"[handlers] Notifying document author={doc_author.pk} "
+ f"about new comment"
+ )
+
+ author_name = author.get_full_name() or author.username
+
+ notify.send(
+ sender=author,
+ recipient=doc_author,
+ verb=NotificationVerbs.DOCUMENT_COMMENT,
+ action_object=comment,
+ description=MessageTemplates.comment(author_name, document.title),
+ action_url=ActionURLs.DOCUMENTS,
+ data={
+ 'title': MessageTemplates.comment_title(),
+ 'document_id': document.id,
+ 'comment_id': comment.id,
+ 'author_id': author.id,
+ },
+ )
+
+
+def notify_comment_reply(parent_comment, reply_comment):
+ """
+ Уведомляет автора комментария об ответе на него.
+
+ Args:
+ parent_comment: Объект DocumentComment (родительский)
+ reply_comment: Объект DocumentComment (ответ)
+ """
+ author = reply_comment.author
+ parent_author = parent_comment.author
+ document = reply_comment.document
+
+ # Не уведомляем самого себя
+ if parent_author.id == author.id:
+ logger.info(
+ "[handlers] Skipping self-reply notification"
+ )
+ return
+
+ logger.info(
+ f"[handlers] Notifying parent author={parent_author.pk} about reply"
+ )
+
+ author_name = author.get_full_name() or author.username
+
+ notify.send(
+ sender=author,
+ recipient=parent_author,
+ verb=NotificationVerbs.DOCUMENT_COMMENT_REPLY,
+ action_object=reply_comment,
+ description=MessageTemplates.comment_reply(author_name, document.title),
+ action_url=ActionURLs.DOCUMENTS,
+ data={
+ 'title': MessageTemplates.comment_reply_title(),
+ 'document_id': document.id,
+ 'comment_id': reply_comment.id,
+ 'parent_comment_id': parent_comment.id,
+ 'author_id': author.id,
+ },
+ )
diff --git a/backend/documents/notifications/signals.py b/backend/documents/notifications/signals.py
new file mode 100644
index 00000000..016a769d
--- /dev/null
+++ b/backend/documents/notifications/signals.py
@@ -0,0 +1,190 @@
+"""
+Django signals для автоматической генерации уведомлений в модуле Documents.
+
+Обрабатывает события:
+- post_save для Document - создание нового документа
+- m2m_changed для Document.recipients - добавление конкретных получателей
+- m2m_changed для Document.departments - добавление отделов
+- post_save для DocumentAcknowledgement - проверка полного ознакомления
+- post_save для DocumentComment - новый комментарий или ответ
+"""
+
+import logging
+
+from django.contrib.auth import get_user_model
+from django.db.models.signals import post_save, m2m_changed
+from django.dispatch import receiver
+
+from ..models import Document, DocumentAcknowledgement, DocumentComment
+from .handlers import (
+ notify_all_employees,
+ notify_specific_users,
+ notify_department_employees,
+ notify_all_acknowledged,
+ notify_document_comment,
+ notify_comment_reply,
+)
+
+logger = logging.getLogger(__name__)
+Employee = get_user_model()
+
+
+@receiver(post_save, sender=Document)
+def create_document_notification(sender, instance, created, **kwargs):
+ """
+ Создает уведомления при создании нового документа.
+
+ Уведомления отправляются:
+ - Всем активным сотрудникам (если sent_to_all=True)
+ - Сотрудникам выбранных отделов (через m2m_changed signal)
+ - Выбранным получателям (через m2m_changed signal)
+ """
+ logger.info(
+ f"[signals] post_save Document id={instance.pk} "
+ f"created={created} sent_to_all={instance.sent_to_all}"
+ )
+
+ if not created:
+ return
+
+ document = instance
+
+ # Если документ отправляется всем - создаем уведомления напрямую
+ # channels.py автоматически отправит через Celery
+ if document.sent_to_all:
+ logger.info(
+ f"[signals] Creating notifications for doc={document.pk} (sent_to_all=True)"
+ )
+ notify_all_employees(document)
+ else:
+ logger.info(
+ f"[signals] Skipping notifications (sent_to_all=False), "
+ f"waiting for m2m_changed"
+ )
+
+
+@receiver(m2m_changed, sender=Document.recipients.through)
+def notify_specific_recipients(sender, instance, action, pk_set, **kwargs):
+ """
+ Уведомляет конкретных получателей при добавлении в список.
+ Использует быструю обработку для большого количества получателей.
+ """
+ logger.info(
+ f"[signals] m2m_changed Document id={instance.pk} "
+ f"action={action} pk_set={pk_set} sent_to_all={instance.sent_to_all}"
+ )
+
+ if action != 'post_add':
+ logger.info(
+ f"[signals] Skipping (action={action} != 'post_add')"
+ )
+ return
+
+ document = instance
+
+ # Не отправляем индивидуальные уведомления если документ для всех
+ if document.sent_to_all:
+ logger.info(
+ "[signals] Skipping (sent_to_all=True)"
+ )
+ return
+
+ # Отправляем уведомления через handlers
+ notify_specific_users(document, pk_set)
+
+
+@receiver(m2m_changed, sender=Document.departments.through)
+def notify_department_employees_signal(sender, instance, action, pk_set, **kwargs):
+ """
+ Уведомляет всех сотрудников выбранных отделов при добавлении отделов.
+ Уведомления получат ВСЕ текущие и будущие сотрудники этих отделов.
+ """
+ logger.info(
+ f"[signals] m2m_changed Document.departments "
+ f"id={instance.pk} action={action} pk_set={pk_set}"
+ )
+
+ if action != 'post_add':
+ logger.info(
+ f"[signals] Skipping (action={action} != 'post_add')"
+ )
+ return
+
+ document = instance
+
+ # Не отправляем если документ для всех
+ if document.sent_to_all:
+ logger.info(
+ "[signals] Skipping (sent_to_all=True)"
+ )
+ return
+
+ # Отправляем уведомления через handlers
+ notify_department_employees(document, pk_set)
+
+
+@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:
+ # Считаем получателей из recipients + departments
+ recipients_count = document.recipients.count()
+
+ # Добавляем сотрудников из отделов
+ from employees.models import Department
+ department_employees = set()
+ for department in document.departments.all():
+ department_employees.update(department.active_employees)
+
+ # Исключаем дубликаты с recipients
+ total_recipients = recipients_count + len(department_employees)
+
+ # Проверяем количество ознакомившихся
+ acknowledged_count = document.acknowledgements.count()
+
+ # Если все ознакомились - уведомляем загрузившего
+ if (acknowledged_count >= total_recipients and
+ total_recipients > 0 and
+ document.uploaded_by):
+
+ notify_all_acknowledged(document, total_recipients, acknowledged_count)
+
+
+@receiver(post_save, sender=DocumentComment)
+def notify_on_new_comment(sender, instance, created, **kwargs):
+ """
+ Отправка уведомлений при создании нового комментария.
+
+ - Если это ответ на комментарий (parent != None): уведомить автора родительского комментария
+ - Если это обычный комментарий: уведомить автора документа (если он не автор комментария)
+ """
+ if not created:
+ return
+
+ comment = instance
+ document = comment.document
+ author = comment.author
+
+ logger.info(
+ f"[signals] New comment on document={document.pk} "
+ f"by user={author.pk} parent={comment.parent_id}"
+ )
+
+ # Если это ответ на комментарий
+ if comment.parent:
+ notify_comment_reply(comment.parent, comment)
+ else:
+ # Обычный комментарий - уведомляем автора документа
+ notify_document_comment(document, comment)
diff --git a/backend/documents/tasks.py b/backend/documents/tasks.py
deleted file mode 100644
index 602e6167..00000000
--- a/backend/documents/tasks.py
+++ /dev/null
@@ -1,177 +0,0 @@
-# # import os
-# # from dotenv import load_dotenv
-# # from asgiref.sync import async_to_sync
-
-# # from aiogram import Bot
-# # from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
-
-# # from .models import Document
-# # from django.contrib.auth import get_user_model
-# # from bots.models import BotSubscriber
-# # import logging
-
-# # logger = logging.getLogger(__name__)
-
-
-# # load_dotenv()
-# # BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
-
-# # User = get_user_model()
-# # telegram_bot = Bot(token=BOT_TOKEN)
-
-
-# # def send_document_to_recipients(document_id: int):
-# # """
-# # Уведомляем в Telegram о появлении нового документа:
-# # — всем активным, если sent_to_all=True,
-# # — или тем, кто в recipients.
-# # """
-# # try:
-# # doc = Document.objects.get(pk=document_id)
-# # except Document.DoesNotExist:
-# # return
-
-# # # собираем подписчиков
-# # if doc.sent_to_all:
-# # subs = BotSubscriber.objects.filter(
-# # telegram_id__isnull=False,
-# # user__is_active=True
-# # )
-# # else:
-# # subs = BotSubscriber.objects.filter(
-# # telegram_id__isnull=False,
-# # user__in=doc.recipients.filter(is_active=True)
-# # )
-
-# # if not subs.exists():
-# # return
-
-# # # клавиатура: ведёт пользователя в бот, в раздел «Документы»
-# # kb = InlineKeyboardMarkup(inline_keyboard=[[
-# # InlineKeyboardButton(text="📄 Открыть документы",
-# # callback_data="menu_docs")
-# # ]])
-
-# # text = (
-# # f"🆕 *Новый документ*: {doc.title}\n\n"
-# # f"{doc.description or ''}\n\n"
-# # f"Зайдите в раздел «Документы» в боте, чтобы скачать файл."
-# # )
-
-# # # шлём всем подписчикам
-# # for sub in subs:
-# # try:
-# # async_to_sync(telegram_bot.send_message)(
-# # chat_id=sub.telegram_id,
-# # text=text,
-# # parse_mode="Markdown",
-# # disable_web_page_preview=True,
-# # reply_markup=kb,
-# # )
-# # except Exception as e:
-# # # Записываем в лог, на каком именно подписчике и с каким текстом упало
-# # logger.error(
-# # f"[Telegram] Ошибка при отправке документу={doc.pk} "
-# # f"пользователю={sub.user.pk} tg_id={sub.telegram_id}: {e}",
-# # exc_info=True
-# # )
-# # else:
-# # logger.info(
-# # f"[Telegram] Уведомление по документу={doc.pk} "
-# # f"успешно отправлено пользователю={sub.user.pk}"
-# # )
-
-
-# # documents/tasks.py
-# import os
-# from dotenv import load_dotenv
-# from celery import shared_task
-# from django.conf import settings
-# from django.urls import reverse
-# from django.utils import timezone
-# from telegram import Bot as TgBot, InlineKeyboardButton, InlineKeyboardMarkup
-
-# from .models import Document
-# from django.contrib.auth import get_user_model
-# from bots.models import BotSubscriber
-# import logging
-
-# logger = logging.getLogger(__name__)
-
-# load_dotenv()
-# TG_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') or settings.TELEGRAM_BOT_TOKEN
-
-# User = get_user_model()
-
-
-# # @shared_task(bind=True, name="documents.send_document_to_recipients")
-# def send_document_to_recipients(document_id):
-# try:
-# doc = Document.objects.get(pk=document_id)
-# except Document.DoesNotExist:
-# return
-
-# # Собираем подписчиков
-# if doc.sent_to_all:
-# subs = BotSubscriber.objects.filter(
-# telegram_id__isnull=False,
-# user__is_active=True,
-# )
-# else:
-# subs = BotSubscriber.objects.filter(
-# telegram_id__isnull=False,
-# user__in=doc.recipients.filter(is_active=True),
-# )
-# if not subs.exists():
-# return
-
-# # Синхронный телеграм-бот
-# bot = TgBot(token=TG_TOKEN)
-
-# # Кнопка «Открыть документы»
-# kb = InlineKeyboardMarkup(
-# [[ InlineKeyboardButton("📄 Открыть документы", callback_data="menu_docs") ]]
-# )
-
-# # Текст уведомления
-# text = (
-# f"🆕 *Новый документ*: {doc.title}\n\n"
-# f"{doc.description or ''}\n\n"
-# f"Дата публикации: {doc.uploaded_at.strftime('%d.%m.%Y %H:%M')}"
-# )
-
-# # for sub in subs:
-# # try:
-# # bot.send_message(
-# # chat_id=sub.telegram_id,
-# # text=text,
-# # parse_mode="Markdown",
-# # reply_markup=kb,
-# # disable_web_page_preview=True
-# # )
-# # except Exception as e:
-# # print(e)
-# # логируем, чтобы понимать, кому не дошло
-# # self.retry(exc=e, countdown=60, max_retries=3)
-
-# for sub in subs:
-# try:
-# bot.send_message(
-# chat_id=sub.telegram_id,
-# text=text,
-# parse_mode="Markdown",
-# disable_web_page_preview=True,
-# reply_markup=kb,
-# )
-# except Exception as e:
-# # Записываем в лог, на каком именно подписчике и с каким текстом упало
-# logger.error(
-# f"[Telegram] Ошибка при отправке документу={doc.pk} "
-# f"пользователю={sub.user.pk} tg_id={sub.telegram_id}: {e}",
-# exc_info=True
-# )
-# else:
-# logger.info(
-# f"[Telegram] Уведомление по документу={doc.pk} "
-# f"успешно отправлено пользователю={sub.user.pk}"
-# )
diff --git a/backend/documents/test_documents_api.py b/backend/documents/test_documents_api.py
deleted file mode 100644
index 5c0a4f88..00000000
--- a/backend/documents/test_documents_api.py
+++ /dev/null
@@ -1,624 +0,0 @@
-# documents/test_documents_api.py
-"""
-Comprehensive pytest API tests для DocumentViewSet
-"""
-
-import pytest
-from django.core.files.uploadedfile import SimpleUploadedFile
-from rest_framework import status
-from filer.models import File as FilerFile
-from documents.models import (
- Document, DocumentAcknowledgement, DocumentComment
-)
-from reversion.models import Version
-import reversion
-
-
-# =============================================================================
-# CRUD TESTS
-# =============================================================================
-
-@pytest.mark.django_db
-class TestDocumentCRUDAPI:
- """Тесты CRUD операций для документов"""
-
- def test_list_documents_authenticated(self, viewer_client, document):
- """GET /documents/ - авторизованный пользователь видит свои документы"""
- response = viewer_client.get('/api/v1/documents/')
- assert response.status_code == status.HTTP_200_OK
- # viewer в recipients - должен видеть документ
- assert len(response.data.get('results', response.data)) >= 1
-
- def test_list_documents_unauthenticated(self, api_client):
- """GET /documents/ - неавторизованный получает 401"""
- response = api_client.get('/api/v1/documents/')
- assert response.status_code == status.HTTP_401_UNAUTHORIZED
-
- def test_create_document(self, authenticated_client, author_user):
- """POST /documents/ - автор может создать документ"""
- file_obj = SimpleUploadedFile("new_doc.txt", b"Content", content_type="text/plain")
-
- data = {
- 'title': 'Новый документ',
- 'description': 'Описание',
- 'sent_to_all': True,
- 'file': file_obj,
- }
-
- response = authenticated_client.post('/api/v1/documents/', data, format='multipart')
- assert response.status_code == status.HTTP_201_CREATED
- assert response.data['title'] == 'Новый документ'
-
- def test_retrieve_document_as_recipient(self, viewer_client, document):
- """GET /documents/{id}/ - получатель может просмотреть"""
- response = viewer_client.get(f'/api/v1/documents/{document.id}/')
- assert response.status_code == status.HTTP_200_OK
- assert response.data['title'] == document.title
-
- def test_retrieve_document_as_non_recipient(self, api_client, other_user, document):
- """GET /documents/{id}/ - не-получатель не видит документ (404 - отфильтрован queryset)"""
- api_client.force_authenticate(user=other_user)
- response = api_client.get(f'/api/v1/documents/{document.id}/')
- # QuerySet фильтрует недоступные документы, поэтому 404
- assert response.status_code == status.HTTP_404_NOT_FOUND
-
- def test_update_document_as_author(self, authenticated_client, document, author_user):
- """PATCH /documents/{id}/ - автор может редактировать"""
- data = {'title': 'Обновленное название'}
- response = authenticated_client.patch(f'/api/v1/documents/{document.id}/', data)
- assert response.status_code == status.HTTP_200_OK
-
- # Проверяем через API response (refresh_from_db не работает с FSM protected fields)
- assert response.data['title'] == 'Обновленное название'
-
- def test_update_document_as_viewer_forbidden(self, viewer_client, document):
- """PATCH /documents/{id}/ - получатель не может редактировать"""
- data = {'title': 'Попытка изменить'}
- response = viewer_client.patch(f'/api/v1/documents/{document.id}/', data)
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
- def test_delete_document_as_author(self, authenticated_client, document):
- """DELETE /documents/{id}/ - автор может удалить"""
- doc_id = document.id
- response = authenticated_client.delete(f'/api/v1/documents/{doc_id}/')
- assert response.status_code == status.HTTP_204_NO_CONTENT
- assert not Document.objects.filter(id=doc_id).exists()
-
- def test_delete_document_as_viewer_forbidden(self, viewer_client, document):
- """DELETE /documents/{id}/ - получатель не может удалить"""
- response = viewer_client.delete(f'/api/v1/documents/{document.id}/')
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
-
-# =============================================================================
-# FSM WORKFLOW TESTS
-# =============================================================================
-
-@pytest.mark.django_db
-class TestDocumentFSMWorkflow:
- """Тесты FSM переходов состояний документа"""
-
- def test_submit_for_review_transition(self, authenticated_client, document):
- """POST submit-for-review: draft → in_review"""
- assert document.status == Document.Status.DRAFT
-
- response = authenticated_client.post(
- f'/api/v1/documents/{document.id}/submit-for-review/'
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data['status'] == Document.Status.IN_REVIEW
-
- def test_approve_transition(self, authenticated_client, document):
- """POST approve: in_review → approved"""
- document.submit_for_review()
- document.save()
-
- response = authenticated_client.post(
- f'/api/v1/documents/{document.id}/approve/'
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data['status'] == Document.Status.APPROVED
-
- def test_reject_transition(self, authenticated_client, document):
- """POST reject: in_review → rejected"""
- document.submit_for_review()
- document.save()
-
- response = authenticated_client.post(
- f'/api/v1/documents/{document.id}/reject/'
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data['status'] == Document.Status.REJECTED
-
- def test_publish_transition(self, authenticated_client, document):
- """POST publish: approved → published"""
- document.submit_for_review()
- document.approve()
- document.save()
-
- response = authenticated_client.post(
- f'/api/v1/documents/{document.id}/publish/'
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data['status'] == Document.Status.PUBLISHED
-
- def test_return_to_draft_transition(self, authenticated_client, document):
- """POST return-to-draft: in_review → draft"""
- document.submit_for_review()
- document.save()
-
- response = authenticated_client.post(
- f'/api/v1/documents/{document.id}/return-to-draft/'
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data['status'] == Document.Status.DRAFT
-
- def test_archive_transition(self, authenticated_client, document):
- """POST archive: published → archived"""
- document.submit_for_review()
- document.approve()
- document.publish()
- document.save()
-
- response = authenticated_client.post(
- f'/api/v1/documents/{document.id}/archive/'
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data['status'] == Document.Status.ARCHIVED
-
- def test_unarchive_transition(self, authenticated_client, document):
- """POST unarchive: archived → published"""
- document.submit_for_review()
- document.approve()
- document.publish()
- document.archive()
- document.save()
-
- response = authenticated_client.post(
- f'/api/v1/documents/{document.id}/unarchive/'
- )
- assert response.status_code == status.HTTP_200_OK
-
- assert document.status == Document.Status.PUBLISHED
-
- def test_invalid_transition_returns_400(self, authenticated_client, document):
- """Невалидный переход возвращает 400"""
- # Пытаемся reject когда status=DRAFT (невалидно)
- response = authenticated_client.post(
- f'/api/v1/documents/{document.id}/reject/'
- )
- assert response.status_code == status.HTTP_400_BAD_REQUEST
-
-
-# =============================================================================
-# VERSIONING TESTS
-# =============================================================================
-
-@pytest.mark.django_db
-class TestDocumentVersioning:
- """Тесты версионирования django-reversion"""
-
- def test_get_versions(self, authenticated_client, document):
- """GET /documents/{id}/versions/ - история версий"""
- # Создаем версии через reversion
- with reversion.create_revision():
- document.title = "Version 1"
- document.save()
- reversion.set_comment("First change")
-
- with reversion.create_revision():
- document.title = "Version 2"
- document.save()
- reversion.set_comment("Second change")
-
- response = authenticated_client.get(
- f'/api/v1/documents/{document.id}/versions/'
- )
- assert response.status_code == status.HTTP_200_OK
-
- versions = response.data
- assert len(versions) >= 2
-
- def test_get_activity_timeline(self, authenticated_client, viewer_user, document):
- """GET /documents/{id}/activity/ - timeline активности"""
- # Создаем ознакомление
- DocumentAcknowledgement.objects.create(
- document=document,
- user=viewer_user
- )
-
- # Создаем версию
- with reversion.create_revision():
- document.title = "Changed"
- document.save()
-
- response = authenticated_client.get(
- f'/api/v1/documents/{document.id}/activity/'
- )
- assert response.status_code == status.HTTP_200_OK
-
- activity = response.data
- assert len(activity) > 0
-
- # Проверяем типы в timeline
- types = [item['type'] for item in activity]
- assert 'version' in types or 'acknowledgement' in types
-
- def test_revert_to_version(self, authenticated_client, document):
- """POST /documents/{id}/revert/ - откат к версии"""
- original_title = "Original Title"
-
- # Сохраняем первую версию
- with reversion.create_revision():
- document.title = original_title
- document.save()
-
- # Получаем первую версию
- versions = Version.objects.get_for_object(document)
- first_version = versions.first()
-
- # Меняем документ
- with reversion.create_revision():
- document.title = "Modified Title"
- document.save()
-
- # Откатываем
- data = {
- 'version_id': first_version.id,
- 'comment': 'Откат'
- }
-
- response = authenticated_client.post(
- f'/api/v1/documents/{document.id}/revert/',
- data
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data['title'] == original_title
-
-
-# =============================================================================
-# ACKNOWLEDGEMENT TESTS
-# =============================================================================
-
-@pytest.mark.django_db
-class TestDocumentAcknowledgement:
- """Тесты ознакомления с документами"""
-
- def test_acknowledge_document(self, viewer_client, viewer_user, document):
- """POST /documents/{id}/acknowledge/ - подтвердить ознакомление"""
- response = viewer_client.post(
- f'/api/v1/documents/{document.id}/acknowledge/'
- )
- assert response.status_code == status.HTTP_200_OK
-
- # Проверяем создание записи
- assert DocumentAcknowledgement.objects.filter(
- document=document,
- user=viewer_user
- ).exists()
-
- def test_acknowledge_non_recipient_forbidden(self, api_client, other_user, document):
- """Не-получатель не может ознакомиться"""
- api_client.force_authenticate(user=other_user)
-
- response = api_client.post(
- f'/api/v1/documents/{document.id}/acknowledge/'
- )
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
- def test_get_acknowledgements_list(self, viewer_client, viewer_user, document):
- """GET /documents/{id}/acknowledgements/ - список ознакомившихся"""
- # Создаем ознакомление
- DocumentAcknowledgement.objects.create(
- document=document,
- user=viewer_user
- )
-
- response = viewer_client.get(
- f'/api/v1/documents/{document.id}/acknowledgements/'
- )
- assert response.status_code == status.HTTP_200_OK
-
- acks = response.data
- assert len(acks) == 1
- assert acks[0]['user']['id'] == viewer_user.id
-
- def test_double_acknowledge_prevented(self, viewer_client, viewer_user, document):
- """Двойное ознакомление запрещено"""
- # Первое ознакомление
- viewer_client.post(f'/api/v1/documents/{document.id}/acknowledge/')
-
- # Второе ознакомление - должно вернуть ошибку или already acknowledged
- response = viewer_client.post(
- f'/api/v1/documents/{document.id}/acknowledge/'
- )
- # Может вернуть 200 с сообщением "already acknowledged" или 400
- # Главное - запись одна
- count = DocumentAcknowledgement.objects.filter(
- document=document,
- user=viewer_user
- ).count()
- assert count == 1
-
-
-# =============================================================================
-# COMMENT TESTS
-# =============================================================================
-
-@pytest.mark.django_db
-class TestDocumentComments:
- """Тесты комментариев к документам"""
-
- def test_create_comment(self, viewer_client, viewer_user, document):
- """POST /document-comments/ - создать комментарий"""
- data = {
- 'text': 'Отличный документ!',
- 'document_id': document.id
- }
-
- response = viewer_client.post('/api/v1/document-comments/', data)
- assert response.status_code == status.HTTP_201_CREATED
-
- assert DocumentComment.objects.filter(
- document=document,
- author=viewer_user
- ).exists()
-
- def test_create_reply_to_comment(self, authenticated_client, viewer_client, viewer_user, author_user, document):
- """Создание ответа на комментарий"""
- # Создаем родительский комментарий
- parent = DocumentComment.objects.create(
- document=document,
- author=viewer_user,
- text='Parent comment'
- )
-
- # Создаем ответ
- data = {
- 'text': 'Reply to parent',
- 'document_id': document.id,
- 'parent_id': parent.id
- }
-
- response = authenticated_client.post('/api/v1/document-comments/', data)
- assert response.status_code == status.HTTP_201_CREATED
-
- reply = DocumentComment.objects.get(parent=parent)
- assert reply.text == 'Reply to parent'
-
- def test_get_comment_replies(self, viewer_client, viewer_user, author_user, document):
- """GET /comments/{id}/replies/ - получить ответы"""
- parent = DocumentComment.objects.create(
- document=document,
- author=viewer_user,
- text='Parent'
- )
-
- DocumentComment.objects.create(
- document=document,
- author=author_user,
- text='Reply 1',
- parent=parent
- )
-
- DocumentComment.objects.create(
- document=document,
- author=author_user,
- text='Reply 2',
- parent=parent
- )
-
- response = viewer_client.get(f'/api/v1/document-comments/{parent.id}/replies/')
- assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 2
-
- def test_delete_own_comment(self, viewer_client, viewer_user, document):
- """Пользователь может удалить свой комментарий"""
- comment = DocumentComment.objects.create(
- document=document,
- author=viewer_user,
- text='My comment'
- )
-
- response = viewer_client.delete(f'/api/v1/document-comments/{comment.id}/')
- assert response.status_code == status.HTTP_204_NO_CONTENT
-
- def test_delete_others_comment_forbidden(self, viewer_client, author_user, document):
- """Нельзя удалить чужой комментарий"""
- comment = DocumentComment.objects.create(
- document=document,
- author=author_user,
- text='Author comment'
- )
-
- response = viewer_client.delete(f'/api/v1/document-comments/{comment.id}/')
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
-
-# =============================================================================
-# PERMISSIONS TESTS
-# =============================================================================
-
-@pytest.mark.django_db
-class TestDocumentPermissions:
- """Тесты системы разрешений"""
-
- def test_sent_to_all_visible_to_everyone(self, api_client, other_user, document_sent_to_all):
- """sent_to_all=True - документ видим всем активным пользователям"""
- api_client.force_authenticate(user=other_user)
-
- response = api_client.get(f'/api/v1/documents/{document_sent_to_all.id}/')
- assert response.status_code == status.HTTP_200_OK
-
- def test_private_document_hidden_from_others(self, api_client, other_user, document):
- """Приватный документ скрыт от не-получателей (404 - отфильтрован)"""
- api_client.force_authenticate(user=other_user)
-
- response = api_client.get(f'/api/v1/documents/{document.id}/')
- # QuerySet фильтрует недоступные документы
- assert response.status_code == status.HTTP_404_NOT_FOUND
-
- def test_department_member_can_view(self, api_client, other_user, document, department):
- """Член отдела-получателя может видеть документ"""
- # Добавляем пользователя в отдел
- other_user.department = department
- other_user.save()
-
- # Добавляем отдел в получатели документа
- document.departments.add(department)
-
- api_client.force_authenticate(user=other_user)
-
- response = api_client.get(f'/api/v1/documents/{document.id}/')
- assert response.status_code == status.HTTP_200_OK
-
- def test_author_can_always_edit(self, authenticated_client, document):
- """Автор всегда может редактировать свой документ"""
- data = {'description': 'Updated by author'}
- response = authenticated_client.patch(
- f'/api/v1/documents/{document.id}/',
- data
- )
- assert response.status_code == status.HTTP_200_OK
-
- def test_admin_has_full_access(self, admin_client, document):
- """Admin имеет полный доступ"""
- # Должен видеть документ
- response = admin_client.get(f'/api/v1/documents/{document.id}/')
- # Admin может иметь доступ или нет в зависимости от rules
- assert response.status_code in [status.HTTP_200_OK, status.HTTP_403_FORBIDDEN]
-
-
-# =============================================================================
-# FOLDER TESTS
-# =============================================================================
-
-@pytest.mark.django_db
-class TestFolderAPI:
- """Тесты для папок документов"""
-
- def test_list_folders(self, authenticated_client, folder):
- """GET /folders/ - список папок"""
- response = authenticated_client.get('/api/v1/folders/')
- assert response.status_code == status.HTTP_200_OK
-
- def test_create_folder(self, authenticated_client):
- """POST /folders/ - создание папки"""
- data = {'name': 'Новая папка'}
- response = authenticated_client.post('/api/v1/folders/', data)
- assert response.status_code == status.HTTP_201_CREATED
-
- def test_get_folder_children(self, authenticated_client, folder, author_user):
- """GET /folders/{id}/children/ - дочерние папки"""
- # Создаем дочернюю папку
- from filer.models import Folder as FilerFolder
- child = FilerFolder.objects.create(
- name='Дочерняя',
- parent=folder,
- owner=author_user
- )
-
- response = authenticated_client.get(f'/api/v1/folders/{folder.id}/children/')
- assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 1
-
- def test_get_folder_documents(self, authenticated_client, folder, document, filer_file):
- """GET /folders/{id}/documents/ - документы в папке"""
- # Перемещаем документ в папку
- document.folder = folder
- document.save()
-
- response = authenticated_client.get(f'/api/v1/folders/{folder.id}/documents/')
- assert response.status_code == status.HTTP_200_OK
- assert len(response.data) == 1
-
-
-# =============================================================================
-# TAG & TYPE TESTS
-# =============================================================================
-
-@pytest.mark.django_db
-class TestDocumentTagsAndTypes:
- """Тесты для тегов и типов документов"""
-
- def test_list_tags(self, authenticated_client, document_tag):
- """GET /document-tags/ - список тегов"""
- response = authenticated_client.get('/api/v1/document-tags/')
- assert response.status_code == status.HTTP_200_OK
-
- def test_create_tag(self, authenticated_client):
- """POST /document-tags/ - создание тега"""
- data = {'name': 'Срочно', 'color': '#FF0000'}
- response = authenticated_client.post('/api/v1/document-tags/', data)
- assert response.status_code == status.HTTP_201_CREATED
-
- def test_list_document_types(self, authenticated_client, document_type):
- """GET /document-types/ - список типов"""
- response = authenticated_client.get('/api/v1/document-types/')
- assert response.status_code == status.HTTP_200_OK
-
- def test_get_documents_by_tag(self, authenticated_client, document, document_tag):
- """GET /tags/{id}/documents/ - документы с тегом"""
- # Добавляем тег к документу (если есть M2M)
- if hasattr(document, 'tags'):
- document.tags.add(document_tag)
-
- response = authenticated_client.get(f'/api/v1/document-tags/{document_tag.id}/documents/')
- assert response.status_code == status.HTTP_200_OK
-
-
-# =============================================================================
-# RELATED DOCUMENTS TESTS
-# =============================================================================
-
-@pytest.mark.django_db
-class TestRelatedDocuments:
- """Тесты для связанных документов"""
-
- def test_get_related_documents(self, authenticated_client, document):
- """GET /documents/{id}/related/ - связанные документы"""
- response = authenticated_client.get(
- f'/api/v1/documents/{document.id}/related/'
- )
- assert response.status_code == status.HTTP_200_OK
-
- def test_add_related_document(self, authenticated_client, document, filer_file, author_user):
- """POST /documents/{id}/add_related/ - добавить связь"""
- # Создаем второй документ
- doc2 = Document.objects.create(
- title="Связанный документ",
- file=filer_file,
- uploaded_by=author_user,
- sent_to_all=True
- )
-
- data = {'document_id': doc2.id}
- response = authenticated_client.post(
- f'/api/v1/documents/{document.id}/add_related/',
- data
- )
- assert response.status_code == status.HTTP_200_OK
-
- # Проверяем связь
- assert doc2 in document.related_documents.all()
-
- def test_remove_related_document(self, authenticated_client, document, filer_file, author_user):
- """POST /documents/{id}/remove_related/ - удалить связь"""
- # Создаем и связываем документ
- doc2 = Document.objects.create(
- title="Связанный",
- file=filer_file,
- uploaded_by=author_user,
- sent_to_all=True
- )
- document.related_documents.add(doc2)
-
- data = {'document_id': doc2.id}
- response = authenticated_client.post(
- f'/api/v1/documents/{document.id}/remove_related/',
- data
- )
- assert response.status_code == status.HTTP_200_OK
-
- # Проверяем что связь удалена
- assert doc2 not in document.related_documents.all()
diff --git a/backend/documents/tests.py b/backend/documents/tests.py
deleted file mode 100644
index 9633ed34..00000000
--- a/backend/documents/tests.py
+++ /dev/null
@@ -1,298 +0,0 @@
-# documents/tests.py
-"""
-Тесты для моделей Document и DocumentAcknowledgement с django-filer
-"""
-
-from django.test import TestCase
-from django.contrib.auth import get_user_model
-from django.core.files.uploadedfile import SimpleUploadedFile
-from filer.models import File as FilerFile
-from documents.models import Document, DocumentAcknowledgement
-from employees.models import Department
-import rules
-
-User = get_user_model()
-
-
-class DocumentModelTest(TestCase):
- """Тесты для модели Document"""
-
- def setUp(self):
- """Создание тестовых данных"""
- self.user = User.objects.create_user(
- phone_number='+79999999999',
- email='test1@example.com',
- first_name='Тест',
- last_name='Тестов',
- is_active=True
- )
-
- # Создаем тестовый файл через filer
- test_file = SimpleUploadedFile(
- "test_document.txt",
- b"Test content",
- content_type="text/plain"
- )
-
- self.filer_file = FilerFile.objects.create(
- file=test_file,
- original_filename="test_document.txt",
- name="Test Document",
- owner=self.user
- )
-
- def test_create_document(self):
- """Создание документа с filer файлом"""
- document = Document.objects.create(
- title="Тестовый документ",
- file=self.filer_file,
- description="Описание тестового документа",
- uploaded_by=self.user,
- sent_to_all=True
- )
-
- self.assertEqual(document.title, "Тестовый документ")
- self.assertEqual(document.file, self.filer_file)
- self.assertEqual(document.uploaded_by, self.user)
- self.assertTrue(document.sent_to_all)
- self.assertIsNotNone(document.uploaded_at)
-
- def test_file_properties(self):
- """Проверка свойств файла (размер, расширение)"""
- document = Document.objects.create(
- title="Тестовый документ",
- file=self.filer_file,
- uploaded_by=self.user
- )
-
- self.assertGreater(document.file_size, 0)
- self.assertEqual(document.file_extension, 'txt')
-
- def test_sent_to_all(self):
- """Документ с sent_to_all=True"""
- document = Document.objects.create(
- title="Документ для всех",
- file=self.filer_file,
- uploaded_by=self.user,
- sent_to_all=True
- )
-
- self.assertTrue(document.sent_to_all)
- self.assertEqual(document.departments.count(), 0)
- self.assertEqual(document.recipients.count(), 0)
-
- def test_department_recipients(self):
- """Документ для конкретных отделов"""
- department = Department.objects.create(
- name="Тестовый отдел"
- )
-
- document = Document.objects.create(
- title="Документ для отдела",
- file=self.filer_file,
- uploaded_by=self.user,
- sent_to_all=False
- )
- document.departments.add(department)
-
- self.assertFalse(document.sent_to_all)
- self.assertEqual(document.departments.count(), 1)
- self.assertIn(department, document.departments.all())
-
- def test_individual_recipients(self):
- """Документ для конкретных получателей"""
- recipient = User.objects.create_user(
- phone_number='+79999999998',
- email='recipient@example.com',
- first_name='Получатель',
- last_name='Тестовый',
- is_active=True
- )
-
- document = Document.objects.create(
- title="Документ для получателя",
- file=self.filer_file,
- uploaded_by=self.user,
- sent_to_all=False
- )
- document.recipients.add(recipient)
-
- self.assertFalse(document.sent_to_all)
- self.assertEqual(document.recipients.count(), 1)
- self.assertIn(recipient, document.recipients.all())
-
-
-class DocumentAcknowledgementModelTest(TestCase):
- """Тесты для модели DocumentAcknowledgement"""
-
- def setUp(self):
- """Создание тестовых данных"""
- self.user = User.objects.create_user(
- phone_number='+79999999999',
- email='test@example.com',
- first_name='Тест',
- last_name='Тестов',
- is_active=True
- )
-
- test_file = SimpleUploadedFile(
- "test_document.txt",
- b"Test content",
- content_type="text/plain"
- )
-
- filer_file = FilerFile.objects.create(
- file=test_file,
- original_filename="test_document.txt",
- name="Test Document",
- owner=self.user
- )
-
- self.document = Document.objects.create(
- title="Тестовый документ",
- file=filer_file,
- uploaded_by=self.user,
- sent_to_all=True
- )
-
- def test_create_acknowledgement(self):
- """Создание ознакомления с документом"""
- ack = DocumentAcknowledgement.objects.create(
- document=self.document,
- user=self.user
- )
-
- self.assertEqual(ack.document, self.document)
- self.assertEqual(ack.user, self.user)
- self.assertIsNotNone(ack.acknowledged_at)
-
- def test_unique_acknowledgement(self):
- """Один пользователь может ознакомиться только один раз"""
- DocumentAcknowledgement.objects.create(
- document=self.document,
- user=self.user
- )
-
- # Попытка создать второе ознакомление должна вызвать ошибку
- with self.assertRaises(Exception):
- DocumentAcknowledgement.objects.create(
- document=self.document,
- user=self.user
- )
-
- def test_acknowledgement_count(self):
- """Подсчет ознакомлений"""
- user2 = User.objects.create_user(
- phone_number='+79999999998',
- email='test2@example.com',
- first_name='Тест2',
- last_name='Тестов2',
- is_active=True
- )
-
- DocumentAcknowledgement.objects.create(
- document=self.document,
- user=self.user
- )
- DocumentAcknowledgement.objects.create(
- document=self.document,
- user=user2
- )
-
- self.assertEqual(self.document.acknowledgements.count(), 2)
-
-
-class DocumentRulesTest(TestCase):
- """Тесты для правил доступа django-rules"""
-
- def setUp(self):
- """Создание тестовых данных"""
- self.owner = User.objects.create_user(
- phone_number='+79999999999',
- email='owner@example.com',
- first_name='Владелец',
- last_name='Документа',
- is_active=True
- )
-
- self.other_user = User.objects.create_user(
- phone_number='+79999999998',
- email='other@example.com',
- first_name='Другой',
- last_name='Пользователь',
- is_active=True
- )
-
- test_file = SimpleUploadedFile(
- "test_document.txt",
- b"Test content",
- content_type="text/plain"
- )
-
- filer_file = FilerFile.objects.create(
- file=test_file,
- original_filename="test_document.txt",
- name="Test Document",
- owner=self.owner
- )
-
- self.document = Document.objects.create(
- title="Тестовый документ",
- file=filer_file,
- uploaded_by=self.owner,
- sent_to_all=False # Не для всех
- )
-
- def test_owner_can_view(self):
- """Владелец может просматривать свой документ"""
- self.assertTrue(
- rules.test_rule('documents.view_document', self.owner, self.document)
- )
-
- def test_other_user_cannot_view(self):
- """Другой пользователь не может просматривать чужой документ (если не в списке)"""
- self.assertFalse(
- rules.test_rule('documents.view_document', self.other_user, self.document)
- )
-
- def test_sent_to_all_allows_view(self):
- """Документ с sent_to_all=True доступен всем активным пользователям"""
- self.document.sent_to_all = True
- self.document.save()
-
- self.assertTrue(
- rules.test_rule('documents.view_document', self.other_user, self.document)
- )
-
- def test_recipient_can_view(self):
- """Получатель из списка может просматривать документ"""
- self.document.recipients.add(self.other_user)
-
- self.assertTrue(
- rules.test_rule('documents.view_document', self.other_user, self.document)
- )
-
- def test_owner_can_edit(self):
- """Владелец может редактировать свой документ"""
- self.assertTrue(
- rules.test_rule('documents.change_document', self.owner, self.document)
- )
-
- def test_other_user_cannot_edit(self):
- """Другой пользователь не может редактировать чужой документ"""
- self.assertFalse(
- rules.test_rule('documents.change_document', self.other_user, self.document)
- )
-
- def test_owner_can_delete(self):
- """Владелец может удалять свой документ"""
- self.assertTrue(
- rules.test_rule('documents.delete_document', self.owner, self.document)
- )
-
- def test_other_user_cannot_delete(self):
- """Другой пользователь не может удалять чужой документ"""
- self.assertFalse(
- rules.test_rule('documents.delete_document', self.other_user, self.document)
- )
-
diff --git a/backend/documents/urls.py b/backend/documents/urls.py
deleted file mode 100644
index a84c8b06..00000000
--- a/backend/documents/urls.py
+++ /dev/null
@@ -1,10 +0,0 @@
-# documents/urls.py
-from django.urls import path
-from .views import DocumentView, acknowledge_document
-
-app_name = 'documents'
-
-urlpatterns = [
- path('', DocumentView.as_view(), name='document_list'),
- path('ack//', acknowledge_document, name='acknowledge'),
-]
diff --git a/backend/documents/views.py b/backend/documents/views.py
deleted file mode 100644
index 72974eb0..00000000
--- a/backend/documents/views.py
+++ /dev/null
@@ -1,435 +0,0 @@
-from __future__ import annotations
-
-from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple
-
-from api.client import get_api_client
-from api.decorators import require_api_auth
-from django.contrib import messages
-from django.contrib.auth.decorators import login_required
-from django.contrib.auth.models import AbstractBaseUser
-from django.contrib.auth.mixins import LoginRequiredMixin
-from django.http import HttpRequest, HttpResponse
-from django.shortcuts import redirect
-from django.urls import reverse
-from django.utils.decorators import method_decorator
-from django.utils.dateparse import parse_datetime
-from django.utils.timezone import localtime
-from django.views.decorators.csrf import csrf_protect
-from django.views.generic import TemplateView
-
-
-# ==============================
-# Вспомогательные функции
-# ==============================
-
-
-def _as_bool(value: Any) -> bool:
- """Нормализует булево значение из строк HTML-форм.
-
- Args:
- value: Строка/булево/None.
-
- Returns:
- bool: True, если значение похоже на истину ('1', 'true', 'on', 'yes').
-
- Raises:
- ValueError: Если значение имеет неожиданный тип, не приводимый к строке.
- """
- if isinstance(value, bool):
- return value
- try:
- s = str(value).strip().lower()
- except Exception as exc: # pragma: no cover - защитный блок
- raise ValueError("Unsupported boolean value") from exc
- return s in {"1", "true", "on", "yes"}
-
-
-def _repeat_field_pairs(field: str, values: Iterable[int | str]) -> List[Tuple[str, str]]:
- """Готовит список повторяемых пар (field, value) для multipart-формы.
-
- Args:
- field (str): Имя поля.
- values (Iterable[int|str]): Значения (могут быть пустыми/None — игнорируются).
-
- Returns:
- list[tuple[str, str]]: Повторяемые пары для передачи в API-клиент.
-
- Raises:
- TypeError: Если field пустой или values неитерируемый.
- """
- if not field:
- raise TypeError("field must be a non-empty string")
- out: List[Tuple[str, str]] = []
- for v in values or []:
- if v is None:
- continue
- s = str(v).strip()
- if s:
- out.append((field, s))
- return out
-
-
-def _api_unpack(resp: Any) -> Tuple[bool, Dict[str, Any], int]:
- """Безопасно извлекает признаки успеха, JSON и статус из ответа API.
-
- Args:
- resp: Объект ответа клиентской обёртки (ожидаются атрибуты .ok, .json, .status).
-
- Returns:
- tuple: (ok, data, status)
-
- Raises:
- AttributeError: Если у ответа нет ожидаемых атрибутов.
- """
- ok: bool = getattr(resp, "ok")
- status: int = getattr(resp, "status")
- data: Dict[str, Any] = getattr(resp, "json") or {}
- return ok, data, status
-
-
-def _parse_page_payload(
- payload: Any,
-) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[str], Optional[int]]:
- """Нормализует ответ API к (results, next, previous, count).
-
- Поддерживает форматы:
- - пагинация DRF: {"count": N, "next": URL, "previous": URL,
- "results": [...]}
- - сырой список: [ ... ]
-
- Args:
- payload (Any): Десериализованный JSON из API.
-
- Returns:
- Tuple: (results, next_url, prev_url, count)
- """
- # 1) Непагинированный список
- if isinstance(payload, list):
- results = _format_datetime_fields(payload)
- return results, None, None, len(results)
-
- # 2) Словарь (пагинированный или нет)
- if isinstance(payload, dict):
- if "results" in payload:
- raw = payload.get("results") or []
- results = _format_datetime_fields(list(raw))
- return (
- results,
- payload.get("next"),
- payload.get("previous"),
- payload.get("count")
- )
- # словарь, но не страница — пустой список
- return [], None, None, None
-
- # 3) Непонятный формат
- return [], None, None, None
-
-
-def _user_can_manage_documents(user: AbstractBaseUser) -> bool:
- """Проверяет, есть ли у пользователя административные/модельные права на Document.
-
- Args:
- user (AbstractBaseUser): Пользователь.
-
- Returns:
- bool: True, если is_staff или есть одно из прав add/change/delete/view.
- """
- if getattr(user, "is_staff", False):
- return True
- model = "document"
- app_label = "documents"
- for act in ("view", "add", "change", "delete"):
- if user.has_perm(f"{app_label}.{act}_{model}"):
- return True
- return False
-
-
-def _fmt_dt(val, fmt: str = "%d.%m.%Y %H:%M") -> str | None:
- """Превращает ISO-строку/дату в локализованную строку.
-
- Args:
- val: ISO строка даты или datetime объект.
- fmt: Формат вывода даты.
-
- Returns:
- str | None: Отформатированная строка или None.
- """
- if not val:
- return None
- # если уже datetime — ок, если строка — парсим
- dt = parse_datetime(val) if isinstance(val, str) else val
- if dt is None:
- return val # на всякий случай оставим как есть
- try:
- return localtime(dt).strftime(fmt) # уважим таймзону пользователя
- except Exception:
- return val
-
-
-def _format_datetime_fields(
- items: List[Any],
- fields: tuple[str, ...] = ("uploaded_at", "created_at", "updated_at"),
-) -> List[Any]:
- """Форматирует поля дат в списке объектов.
-
- Args:
- items: Список объектов (словарей).
- fields: Кортеж имен полей с датами.
-
- Returns:
- List[Any]: Список с отформатированными датами.
- """
- out: List[Any] = []
- for it in items:
- if isinstance(it, Mapping):
- d = dict(it) # копия, не мутируем исходник
- for f in fields:
- if f in d:
- d[f] = _fmt_dt(d.get(f))
- out.append(d)
- else:
- out.append(it)
- return out
-
-
-def _err_msg(payload: Any, fallback: str = "") -> str:
- """Извлекает сообщение об ошибке из ответа API.
-
- Args:
- payload: Полезная нагрузка ответа API.
- fallback: Сообщение по умолчанию.
-
- Returns:
- str: Сообщение об ошибке.
- """
- # dict: detail / non_field_errors / первое поле со строкой
- if isinstance(payload, dict):
- if isinstance(payload.get("detail"), (str, list)):
- d = payload["detail"]
- return d if isinstance(d, str) else "; ".join(map(str, d))
- if isinstance(payload.get("non_field_errors"), list):
- return "; ".join(map(str, payload["non_field_errors"]))
- # первая строка из значений полей
- for v in payload.values():
- if isinstance(v, list) and v and isinstance(v[0], str):
- return "; ".join(map(str, v))
- if isinstance(v, str):
- return v
- # list: склеить
- if isinstance(payload, list):
- return "; ".join(map(str, payload))
- # str или что-то иное
- return str(payload or fallback)
-
-
-def _current_url_with(request: HttpRequest, **new_params: Any) -> str:
- """Возвращает URL текущего пути с подменой/удалением query-параметров.
-
- Args:
- request (HttpRequest): Текущий запрос.
- **new_params: Пары ключ-значение; None удаляет параметр.
-
- Returns:
- str: Абсолютный путь с новой query-строкой.
- """
- q = request.GET.copy()
- for k, v in new_params.items():
- if v is None and k in q:
- del q[k]
- elif v is not None:
- q[k] = str(v)
- qs = q.urlencode()
- return f"{request.path}?{qs}" if qs else request.path
-
-
-def _filter_for_user(items: Sequence[Dict[str, Any]], user_id: Optional[int]) -> List[Dict[str, Any]]:
- """Фильтрует документы в область «Мои».
-
- Документ считается «моим», если:
- - он отправлен всем (`sent_to_all == true`), или
- - в `recipients[].id` присутствует `user_id`, или
- - в `departments` есть отдел, где пользователь является активным сотрудником, или
- - пользователь является загрузившим документ (`uploaded_by.id == user_id`).
-
- Args:
- items (Sequence[dict]): Полученный список документов из API.
- user_id (int|None): Идентификатор текущего пользователя.
-
- Returns:
- list[dict]: Отфильтрованный список.
-
- Raises:
- ValueError: Если user_id не задан (None).
- """
- if user_id is None:
- raise ValueError("user_id is required for filtering")
- result: List[Dict[str, Any]] = []
- for it in items:
- if not isinstance(it, dict):
- continue
-
- # 1. Документы для всех
- if it.get("sent_to_all"):
- result.append(it)
- continue
-
- # 2. Документы, загруженные текущим пользователем
- uploaded_by = it.get("uploaded_by")
- if uploaded_by and isinstance(uploaded_by, dict):
- try:
- if int(uploaded_by.get("id")) == user_id:
- result.append(it)
- continue
- except (ValueError, TypeError):
- pass
-
- # 3. Проверяем recipients
- recips = it.get("recipients") or []
- try:
- if any(int(r.get("id")) == user_id for r in recips if isinstance(r, dict) and r.get("id") is not None):
- result.append(it)
- continue
- except Exception:
- pass
-
- # 4. Проверяем departments
- depts = it.get("departments") or []
- try:
- # Если в документе есть отдел, нужно проверить членство
- # Но API возвращает только id и name отдела, без списка сотрудников
- # Поэтому нужно загрузить эту информацию отдельно или доверять backend'у
- # Для упрощения, если есть departments - считаем что пользователь может видеть
- # (реальная проверка происходит на backend при создании уведомлений)
- if depts:
- # TODO: добавить проверку членства в отделе через отдельный API запрос
- # Пока просто пропускаем - backend сам решит, нужно ли показывать
- pass
- except Exception:
- pass
- return result
-
-
-# ==============================
-# Вьюхи
-# ==============================
-
-@method_decorator([login_required, require_api_auth, csrf_protect], name="dispatch")
-class DocumentView(LoginRequiredMixin, TemplateView):
- """Страница списка документов с SSR + API для CRUD.
-
- GET:
- - Загружает список документов через API (GET /api/v1/documents/).
- - Понимает фильтры: scope=mine|all, ack_status=acked|not_acked.
- - Для scope=mine загружает список ознакомленных документов.
-
- Контекст шаблона:
- documents, acked_ids, next_url, prev_url, count, scope,
- show_admin_controls, can_manage_documents, filters, perms,
- api_document_list_url, api_document_detail_base.
- """
-
- template_name = "documents/document_list.html"
-
- def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
- """Формирует контекст страницы списка, фильтров и пагинации."""
- ctx = super().get_context_data(**kwargs)
- request = self.request
- user = request.user
-
- # --- Определяем права ---
- can_manage = _user_can_manage_documents(user)
- show_admin_controls = can_manage
-
- # --- Читаем фильтры из GET ---
- scope = (request.GET.get("scope") or "mine").strip().lower()
- if scope not in ("mine", "all"):
- scope = "mine"
-
- # Если нет прав управления - принудительно mine
- if not can_manage:
- scope = "mine"
-
- ack_status = (request.GET.get("ack_status") or "").strip()
-
- # --- Формируем контекст без загрузки документов ---
- # Документы загружаются через AJAX
- ctx.update({
- "documents": [], # Пустой список - загрузка через JS
- "acked_ids": set(),
- "scope": scope,
- "show_admin_controls": show_admin_controls,
- "can_manage_documents": can_manage,
- "filters": {
- "ack_status": ack_status,
- },
- "perms": {
- "documents": {
- "add_document": (
- user.has_perm("documents.add_document") or
- user.is_staff
- ),
- "change_document": (
- user.has_perm("documents.change_document") or
- user.is_staff
- ),
- "delete_document": (
- user.has_perm("documents.delete_document") or
- user.is_staff
- ),
- }
- },
- "api_document_list_url": reverse("api:v1:documents-list"),
- "api_document_detail_base": reverse("api:v1:documents-list"),
- })
-
- return ctx
-
-
-@login_required
-@require_api_auth
-def acknowledge_document(request: HttpRequest, pk: int) -> HttpResponse:
- """Отмечает документ «ознакомленным» через API и перенаправляет на файл.
-
- Args:
- request (HttpRequest): Текущий запрос.
- pk (int): Идентификатор документа.
-
- Returns:
- HttpResponse: Редирект на файл документа или список документов при ошибке.
-
- Raises:
- ValueError: Если pk имеет неверный формат.
- """
- try:
- doc_id = int(pk)
- except (TypeError, ValueError) as exc:
- messages.error(request, "Некорректный идентификатор документа.")
- raise ValueError("pk must be an integer") from exc
-
- # Получаем документ для редиректа на файл
- from documents.models import Document
- try:
- doc = Document.objects.get(id=doc_id)
- file_url = doc.file.url if doc.file else None
- except Document.DoesNotExist:
- messages.error(request, "Документ не найден.")
- return redirect(reverse("documents:document_list"))
-
- # Отмечаем ознакомление через API
- api = get_api_client(request)
- resp = api.post(f"v1/documents/{doc_id}/acknowledge/")
- ok, data, _status = _api_unpack(resp)
- if ok:
- if data.get("already"):
- messages.info(request, "Вы уже отмечали ознакомление с этим документом.")
- else:
- messages.success(request, "Ознакомление с документом отмечено.")
- else:
- messages.error(request, data.get("detail") or "Не удалось отметить документ.")
-
- # Редирект на файл или обратно на список
- if file_url:
- return redirect(file_url)
- else:
- return redirect(reverse("documents:document_list"))
diff --git a/backend/employees/authentication.py b/backend/employees/authentication.py
deleted file mode 100644
index bcd9df47..00000000
--- a/backend/employees/authentication.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# backend\employees\authentication.py
-from django.contrib.auth.backends import ModelBackend
-
-from .models import Employee
-
-
-class EmailOrPhoneBackend(ModelBackend):
- def authenticate(self, request, username=None, password=None, **kwargs):
- if username is None:
- username = kwargs.get("username")
-
- try:
- # Проверяем, email ли это
- if "@" in username:
- user = Employee.objects.get(email=username)
- else:
- # Иначе phone
- user = Employee.objects.get(phone_number=username)
- except Employee.DoesNotExist:
- return None
-
- if user.check_password(password) and self.user_can_authenticate(user):
- return user
- return None
-
- def get_user(self, user_id):
- try:
- return Employee.objects.get(pk=user_id)
- except Employee.DoesNotExist:
- return None
diff --git a/backend/employees/constants.py.orig b/backend/employees/constants.py.orig
deleted file mode 100644
index 73f01094..00000000
--- a/backend/employees/constants.py.orig
+++ /dev/null
@@ -1,53 +0,0 @@
-# backend\employees\constants.py
-GENDER_CHOICES = (
- (1, "Мужской"),
- (2, "Женский"),
- (0, "Не указан"),
-)
-
-
-ACTION_HIRED = "hired"
-ACTION_DISMISSED = "dismissed"
-ACTION_ON_LEAVE = "on_leave"
-ACTION_RETURNED_FROM_LEAVE = "returned_from_leave"
-ACTION_ON_MATERNITY = "on_maternity"
-ACTION_RETURNED_FROM_MATERNITY = "returned_from_maternity"
-ACTION_TRANSFERRED = "transferred"
-ACTION_REHIRED = "rehired"
-
-ACTION_CHOICES = [
- (ACTION_HIRED, "Принят"),
- (ACTION_DISMISSED, "Уволен"),
- (ACTION_ON_LEAVE, "В отпуске"),
- (ACTION_RETURNED_FROM_LEAVE, "Вернулся из отпуска"),
- (ACTION_ON_MATERNITY, "В декрете"),
- (ACTION_RETURNED_FROM_MATERNITY, "Вернулся из декрета"),
- (ACTION_TRANSFERRED, "Переведен"),
- (ACTION_REHIRED, "Восстановлен"),
-]
-
-
-class DeptPerm:
- MANAGE = "manage_department"
- CHANGE_HEAD = "change_department_head"
- ASSIGN_ROLE = "assign_department_role"
- MANAGE_CALENDAR = "manage_department_events"
- CREATE_POST = "publish_department_post"
- MANAGE_FEED = "manage_department_feed"
- VIEW_REQUESTCOMMENT = "view_requestcomment"
- ADD_REQUESTCOMMENT = "add_requestcomment"
-
- CHOICES = (
- (MANAGE, "Управлять отделом"),
- (CHANGE_HEAD, "Назначать руководителя"),
- (ASSIGN_ROLE, "Назначать роли участникам"),
- (MANAGE_CALENDAR, "Управлять календарём отдела"),
- (CREATE_POST, "Публиковать новости на странице отдела"),
- (MANAGE_FEED, "Редактировать публикации отдела"),
- (VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"),
- (ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),
-<<<<<<< HEAD
- )
-=======
- )
->>>>>>> origin/feat/ldap-writeback
diff --git a/backend/employees/forms_auth.py b/backend/employees/forms_auth.py
deleted file mode 100644
index 045360e8..00000000
--- a/backend/employees/forms_auth.py
+++ /dev/null
@@ -1,386 +0,0 @@
-# backend/employees/forms_auth.py
-from __future__ import annotations
-
-import base64
-import logging
-import re
-from typing import Any, Dict, List, Optional
-
-from django import forms
-from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm
-from django.core.files.uploadedfile import UploadedFile
-from PIL import Image
-
-logger = logging.getLogger(__name__)
-
-
-class EmailOrPhoneAuthenticationForm(AuthenticationForm):
- username = forms.CharField(
- label="Email или телефон",
- widget=forms.TextInput(
- attrs={
- "autofocus": True,
- "placeholder": "user@example.com или +7 999 123-45-67",
- }
- ),
- )
- password = forms.CharField(
- label="Пароль",
- strip=False,
- widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}),
- )
-
-
-class RegistrationForm(forms.Form):
- """Форма регистрации для POST /api/v1/auth/register/.
-
- Валидирует обязательные поля, совпадение паролей и наличие хотя бы одного контактного способа.
- Метод :meth:`to_api_payload` готовит JSON-совместимый payload под ваш DRF-сериализатор,
- включая аватар, закодированный как data URI (base64).
-
- Notes:
- Вызывайте :meth:`to_api_payload` только после успешной валидации (`form.is_valid()`).
-
- Raises:
- ValidationError: Бросается не напрямую, а через `self.add_error(...)` — форма станет невалидной.
- """
-
- # Обязательные поля
- first_name = forms.CharField(max_length=150, label="Имя")
- last_name = forms.CharField(max_length=150, label="Фамилия")
- phone_number = forms.CharField(
- max_length=17, # + и до 15 цифр (с запасом)
- label="Номер телефона",
- widget=forms.TextInput(attrs={
- "type": "tel",
- "inputmode": "numeric",
- "autocomplete": "tel",
- "title": "Введите номер в формате +79991234567",
- })
- )
- email = forms.EmailField(label="Email")
- birth_date = forms.DateField(
- label="Дата рождения", widget=forms.DateInput(attrs={"type": "date"})
- )
-
- # Пароли как в шаблоне (password1/password2)
- password1 = forms.CharField(label="Пароль", widget=forms.PasswordInput)
- password2 = forms.CharField(label="Повторите пароль", widget=forms.PasswordInput)
-
- # Контакты: нужен хотя бы один
- telegram = forms.CharField(required=False, label="Telegram")
- whatsapp = forms.CharField(required=False, label="WhatsApp")
- wechat = forms.CharField(required=False, label="WeChat")
-
- # Опциональные поля модели
- patronymic = forms.CharField(required=False, label="Отчество")
- gender = forms.ChoiceField(
- required=True,
- label="Пол",
- choices=[(1, "Мужской"), (2, "Женский")],
- widget=forms.RadioSelect,
- error_messages={
- 'required': 'Необходимо указать пол.',
- 'invalid_choice': 'Выберите Мужской или Женский.',
- }
- )
- position = forms.IntegerField(required=False, label="ID должности")
- # skills — список ID через запятую
- skills = forms.CharField(required=False, label="Навыки (ID через запятую)")
- avatar = forms.ImageField(required=True, label="Аватар")
-
- def clean_first_name(self) -> str:
- """Валидация имени: только буквы, пробелы и дефисы."""
- value = self.cleaned_data.get("first_name", "").strip()
- if not value:
- raise forms.ValidationError("Имя обязательно для заполнения.")
- if re.search(r'\d', value):
- raise forms.ValidationError(
- "Имя не должно содержать цифры."
- )
- # Разрешаем буквы (любых алфавитов), пробелы, дефисы, апострофы
- if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
- raise forms.ValidationError(
- "Имя может содержать только буквы, пробелы, дефисы и апострофы."
- )
- return value
-
- def clean_last_name(self) -> str:
- """Валидация фамилии: только буквы, пробелы и дефисы."""
- value = self.cleaned_data.get("last_name", "").strip()
- if not value:
- raise forms.ValidationError("Фамилия обязательна для заполнения.")
- if re.search(r'\d', value):
- raise forms.ValidationError(
- "Фамилия не должна содержать цифры."
- )
- if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
- raise forms.ValidationError(
- "Фамилия может содержать только буквы, пробелы, дефисы и апострофы."
- )
- return value
-
- def clean_patronymic(self) -> str:
- """Валидация отчества: только буквы, пробелы и дефисы."""
- value = self.cleaned_data.get("patronymic", "").strip()
- if not value:
- return value # Отчество опциональное
- if re.search(r'\d', value):
- raise forms.ValidationError(
- "Отчество не должно содержать цифры."
- )
- if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
- raise forms.ValidationError(
- "Отчество может содержать только буквы, пробелы, дефисы и апострофы."
- )
- return value
-
- def clean(self) -> Dict[str, Any]:
- """Глобальная валидация формы.
-
- Проверяет:
- - наличие хотя бы одного контактного поля (Telegram/WhatsApp/WeChat);
- - корректность паролей (оба заполнены и совпадают).
-
- Returns:
- dict: Очищенные данные формы.
- """
- data = super().clean()
-
- # хотя бы один контакт
- if not (
- (data.get("telegram") or "").strip()
- or (data.get("whatsapp") or "").strip()
- or (data.get("wechat") or "").strip()
- ):
- self.add_error(
- None,
- "Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram",
- )
-
- # пароли
- p1 = (data.get("password1") or "").strip()
- p2 = (data.get("password2") or "").strip()
- if not p1:
- self.add_error("password1", "Введите пароль.")
- if not p2:
- self.add_error("password2", "Подтвердите пароль.")
- if p1 and p2 and p1 != p2:
- self.add_error("password2", "Пароли не совпадают.")
-
- return data
-
- def skills_ids(self) -> List[int]:
- """Возвращает список ID навыков из текстового поля.
-
- Returns:
- list[int]: Список целых ID. Пустой, если поле пустое или состоит из мусора.
- """
- raw = (self.cleaned_data.get("skills") or "").strip()
- if not raw:
- return []
- out: List[int] = []
- for chunk in raw.split(","):
- s = chunk.strip()
- if not s:
- continue
- try:
- out.append(int(s))
- except ValueError:
- # игнорируем мусор
- continue
- return out
-
- # ---------- Вспомогательные методы для аватара ----------
-
- @staticmethod
- def _guess_mime(upload: UploadedFile, content: bytes) -> str:
- """Определяет MIME-тип изображения.
-
- Args:
- upload: Загруженный файл (может содержать content_type).
- content: Байты файла.
-
- Returns:
- str: MIME-тип, например 'image/jpeg'.
- """
- # 1) используем content_type, если его дал браузер/клиент
- ct = getattr(upload, "content_type", None)
- if ct and ct.startswith("image/"):
- return ct
-
- # 2) пробуем Pillow
- if Image is not None:
- try:
- im = Image.open(upload) # type: ignore[arg-type]
- fmt = (im.format or "").upper()
- mime = Image.MIME.get(fmt)
- if mime:
- # откатим указатель, чтобы не ломать повторные чтения
- try:
- upload.seek(0)
- except Exception:
- pass
- return mime
- except Exception:
- try:
- upload.seek(0)
- except Exception:
- pass
-
- # 3) дефолт
- return "image/jpeg"
-
- @staticmethod
- def _file_to_data_uri(upload: UploadedFile) -> Optional[str]:
- """Кодирует изображение в data URI (base64).
-
- Args:
- upload: Загруженный файл.
-
- Returns:
- Optional[str]: Строка вида 'data:;base64,' или None, если нет данных.
-
- Raises:
- ValueError: Если файл нельзя прочитать.
- """
- if not upload:
- return None
- try:
- content: bytes = upload.read()
- try:
- upload.seek(0)
- except Exception:
- pass
- except Exception as exc:
- raise ValueError("Не удалось прочитать файл аватара.") from exc
-
- if not content:
- return None
-
- mime = RegistrationForm._guess_mime(upload, content)
- b64 = base64.b64encode(content).decode("ascii")
- return f"data:{mime};base64,{b64}"
-
- # ---------- Публичные методы ----------
-
- def to_api_payload(self) -> Dict[str, Any]:
- """Готовит JSON payload для POST `/api/v1/auth/register/`.
-
- Включает:
- - все обязательные поля;
- - контакты (как пустые строки, если не указаны);
- - дополнительные поля (если заданы);
- - `avatar` как data URI (base64), если файл загружен.
-
- Returns:
- dict[str, Any]: Готовый словарь.
-
- Raises:
- KeyError: Если метод вызван до `is_valid()` и ключей нет в `cleaned_data`.
- ValueError: При проблеме чтения файла аватара.
- """
- cd = self.cleaned_data # предполагается, что форма валидна
-
- payload: Dict[str, Any] = {
- "first_name": cd["first_name"],
- "last_name": cd["last_name"],
- "phone_number": cd["phone_number"],
- "email": cd["email"],
- "birth_date": cd["birth_date"].isoformat(),
- "password": cd["password1"],
- "gender": int(cd["gender"]), # обязательное поле
- "telegram": cd.get("telegram") or "",
- "whatsapp": cd.get("whatsapp") or "",
- "wechat": cd.get("wechat") or "",
- }
-
- # Доп. поля
- if cd.get("patronymic"):
- payload["patronymic"] = cd["patronymic"]
- if cd.get("position") is not None:
- payload["position"] = cd["position"]
-
- ids = self.skills_ids()
- if ids:
- payload["skills"] = ids
-
- # Аватар → base64 data URI (совместимо с Base64ImageField)
- avatar_file = cd.get("avatar")
- if avatar_file:
- data_uri = self._file_to_data_uri(avatar_file)
- if data_uri:
- payload["avatar"] = data_uri
-
- return payload
-
-
-class VerifyEmailForm(forms.Form):
- email = forms.EmailField(label="Email")
- code = forms.CharField(max_length=32, label="Код из письма")
-
-class CustomPasswordResetForm(PasswordResetForm):
- """Кастомная форма восстановления пароля с логирование"""
-
- def save(self, *args, **kwargs):
- """Переопределяем save для логирования процесса отправки"""
- email = self.cleaned_data["email"]
-
- logger.info(
- f"\n{'='*80}\n"
- f"[CustomPasswordResetForm.save] 🔐 СОХРАНЕНИЕ ФОРМЫ ВОССТАНОВЛЕНИЯ ПАРОЛЯ\n"
- f" Email: {email}\n"
- f"{'='*80}"
- )
-
- logger.info(
- f"[CustomPasswordResetForm.save] ➡️ Вызов super().save() для отправки emails..."
- )
-
- try:
- result = super().save(*args, **kwargs)
- logger.info(
- f"[CustomPasswordResetForm.save] ✅ super().save() успешно завершен"
- )
- return result
- except Exception as e:
- logger.error(
- f"[CustomPasswordResetForm.save] ❌ Ошибка в super().save(): {type(e).__name__}: {e}",
- exc_info=True
- )
- raise
-
- def send_mail(self, subject_template_name, email_template_name, context,
- from_email, to_email, html_email_template_name=None, **kwargs):
- """Переопределяем send_mail для логирования отправки каждого письма"""
- logger.info(
- f"\n{'='*80}\n"
- f"[CustomPasswordResetForm.send_mail] 📧 ОТПРАВКА EMAIL\n"
- f" To: {to_email}\n"
- f" Subject template: {subject_template_name}\n"
- f" Email template: {email_template_name}\n"
- f" HTML template: {html_email_template_name}\n"
- f"{'='*80}"
- )
-
- try:
- # Вызываем родительский метод
- result = super().send_mail(
- subject_template_name=subject_template_name,
- email_template_name=email_template_name,
- context=context,
- from_email=from_email,
- to_email=to_email,
- html_email_template_name=html_email_template_name,
- **kwargs
- )
- logger.info(
- f"[CustomPasswordResetForm.send_mail] ✅ EMAIL УСПЕШНО ОТПРАВЛЕН на {to_email}"
- )
- return result
- except Exception as e:
- logger.error(
- f"[CustomPasswordResetForm.send_mail] ❌ ОШИБКА ОТПРАВКИ EMAIL: {type(e).__name__}: {e}",
- exc_info=True
- )
- raise
\ No newline at end of file
diff --git a/backend/employees/forms_front.py b/backend/employees/forms_front.py
deleted file mode 100644
index 035a428b..00000000
--- a/backend/employees/forms_front.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# backend\employees\forms_front.py
-from django import forms
-
-from .models import Department, Employee
-
-
-class DepartmentEditForm(forms.Form):
- name = forms.CharField(max_length=255, label="Название")
- description = forms.CharField(
- required=False, widget=forms.Textarea, label="Описание"
- )
-
-
-class SetHeadForm(forms.Form):
- # просто номер id; мы не тянем ORM
- head_id = forms.IntegerField(
- required=False, label="ID руководителя (пусто — снять)"
- )
-
-
-class SetMemberRoleForm(forms.Form):
- employee_id = forms.IntegerField(label="ID сотрудника")
- role_id = forms.IntegerField(required=False, label="ID роли (пусто — снять)")
- is_active = forms.BooleanField(required=False, label="Активен в отделе")
-
-
-class DepartmentForm(forms.ModelForm):
- class Meta:
- model = Department
- fields = ["name", "description", "head"]
-
-
-class EmployeeForm(forms.ModelForm):
- class Meta:
- model = Employee
- fields = (
- "first_name",
- "last_name",
- "patronymic",
- "telegram",
- "whatsapp",
- "wechat",
- )
- widgets = {
- "first_name": forms.TextInput(attrs={"autocomplete": "given-name"}),
- "last_name": forms.TextInput(attrs={"autocomplete": "family-name"}),
- "whatsapp": forms.TextInput(attrs={"inputmode": "tel"}),
- "telegram": forms.TextInput(),
- "wechat": forms.TextInput(),
- }
- labels = {
- "first_name": "Имя",
- "last_name": "Фамилия",
- "patronymic": "Отчество",
- "telegram": "Telegram",
- "whatsapp": "WhatsApp",
- "wechat": "WeChat",
- }
diff --git a/backend/employees/signals.py b/backend/employees/signals.py
index cd69420c..72b452f3 100644
--- a/backend/employees/signals.py
+++ b/backend/employees/signals.py
@@ -7,93 +7,6 @@ from django.utils import timezone
from .constants import ACTION_HIRED
from .models import Employee, EmployeeAction
-from bots.models import BotSubscriber
-
-
-if False: # pragma: no cover - временно отключаем сигнал для тестирования
- @receiver(pre_save, sender=Employee)
- def compress_and_cleanup_avatar(sender, instance: Employee, **kwargs):
- """Сжимает новый аватар и удаляет старый файл при замене."""
- # Импортируем здесь, чтобы избежать циклических импортов
- from common.image_utils import compress_avatar
-
- avatar_field = instance.avatar
- avatar_name = getattr(avatar_field, "name", "")
- if avatar_field and avatar_name:
- storage = avatar_field.storage
- if storage.exists(avatar_name):
- try:
- avatar_field.open("rb")
- avatar_field.seek(0)
- original_data = avatar_field.read()
-
- compressed_data = compress_avatar(original_data)
-
- if len(compressed_data) < len(original_data):
- filename = avatar_name.split("/")[-1]
- if not filename.lower().endswith(".jpg"):
- filename = filename.rsplit(".", 1)[0] + ".jpg"
-
- avatar_field.save(
- filename,
- ContentFile(compressed_data),
- save=False,
- )
- except FileNotFoundError:
- print(
- "Avatar file missing during compression: "
- f"{avatar_name}"
- )
- except Exception as e:
- # Логируем ошибку, но не прерываем сохранение
- print(f"Error compressing avatar: {e}")
-
- # Удаляем старый файл если он был заменен
- if instance.pk is None:
- return
-
- try:
- old = sender.objects.get(pk=instance.pk)
- except sender.DoesNotExist:
- return
-
- # Если аватар изменился или удален
- if old.avatar and old.avatar != instance.avatar:
- # Удаляем старый файл с диска
- if os.path.isfile(old.avatar.path):
- try:
- os.remove(old.avatar.path)
- except Exception as e:
- print(f"Error deleting old avatar: {e}")
-
-
-@receiver(pre_save, sender=Employee)
-def unbind_bot_subscriber_on_contact_change(
- sender, instance: Employee, **kwargs
-):
- # Игнорируем новых пользователей
- if instance.pk is None:
- return
-
- old = sender.objects.get(pk=instance.pk)
- try:
- sub = old.bot_subscription
- except BotSubscriber.DoesNotExist:
- return
-
- # Если очистили или изменили telegram в профиле — сбросим telegram_id
- if old.telegram and old.telegram != (instance.telegram or "").strip():
- sub.telegram_id = None
- sub.save(update_fields=["telegram_id"])
-
- # Аналогично для whatsapp
- if old.whatsapp and old.whatsapp != instance.whatsapp:
- sub.whatsapp_id = None
- sub.save(update_fields=["whatsapp_id"])
-
- if old.wechat and old.wechat != instance.wechat:
- sub.wechat_id = None
- sub.save(update_fields=["wechat_id"])
@receiver(post_save, sender=Employee)
diff --git a/backend/employees/tests/__init__.py b/backend/employees/tests/__init__.py
deleted file mode 100644
index 0d2bf69c..00000000
--- a/backend/employees/tests/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""
-Тесты для employees приложения.
-"""
diff --git a/backend/employees/tests/test_birthday_services.py b/backend/employees/tests/test_birthday_services.py
deleted file mode 100644
index 3afa5e36..00000000
--- a/backend/employees/tests/test_birthday_services.py
+++ /dev/null
@@ -1,332 +0,0 @@
-"""
-Тесты для сервисного слоя работы с днями рождения (Service Layer Pattern).
-Используется django-service-objects для инкапсуляции бизнес-логики.
-"""
-import pytest
-from datetime import date
-from django.contrib.auth import get_user_model
-from django.db.models import signals
-from django.utils import timezone
-
-from employees.models import Employee
-from employees.services import (
- UpsertBirthdayEventService,
- DeleteBirthdayEventService,
- BulkSyncBirthdaysService
-)
-from schedule.models import Calendar, Event, Rule
-
-User = get_user_model()
-
-
-@pytest.fixture(autouse=True)
-def disable_birthday_signals(settings):
- """Отключаем сигналы дней рождений для изолированного тестирования сервисов."""
- from unittest.mock import patch
-
- # Мокируем только функции-обработчики сигналов, а не сами сервисы
- with patch('employees.signals_birthday.sync_birthday_event_on_employee_save'):
- with patch('employees.signals_birthday.delete_birthday_event_on_employee_delete'):
- yield
-
-
-@pytest.fixture
-def create_test_employee():
- """Фикстура для создания тестовых сотрудников с валидными данными."""
- employee_counter = 0
-
- def _create_employee(**kwargs):
- nonlocal employee_counter
- employee_counter += 1
- defaults = {
- 'username': f'testuser{employee_counter}',
- 'email': f'testuser{employee_counter}@example.com',
- 'phone_number': f'+7900000{employee_counter:04d}',
- 'password': 'test123',
- 'first_name': 'Test',
- 'last_name': f'User{employee_counter}'
- }
- defaults.update(kwargs)
-
- # Employee наследуется от AbstractUser
- employee = Employee.objects.create_user(**defaults)
- return employee
-
- return _create_employee
-
-
-@pytest.mark.django_db
-class TestUpsertBirthdayEventService:
- """Тесты для сервиса создания/обновления событий дня рождения."""
-
- def test_create_birthday_event_for_new_employee(self, create_test_employee):
- """Создание нового события дня рождения."""
- # Arrange
- employee = create_test_employee(
- username='john',
- email='john@example.com',
- first_name='John',
- last_name='Doe',
- birth_date=date(1990, 5, 15)
- )
-
- # Act
- result = UpsertBirthdayEventService.execute({'employee': employee})
-
- # Assert
- assert result['success'] is True
- assert result['created'] is True
- assert result['event'] is not None
-
- event = result['event']
- assert event.title == '🎂 День рождения: John Doe'
- assert event.creator_id == employee.pk
- assert event.rule is not None
- assert event.rule.frequency == 'YEARLY'
- assert event.end_recurring_period is None # Бесконечное повторение
-
- def test_update_existing_birthday_event(self, create_test_employee):
- """Обновление существующего события при изменении данных."""
- # Arrange
- employee = create_test_employee(
- username='jane',
- email='jane@example.com',
- first_name='Jane',
- last_name='Smith',
- birth_date=date(1985, 3, 20)
- )
-
- # Создаем событие
- first_result = UpsertBirthdayEventService.execute({'employee': employee})
- first_event_id = first_result['event'].id
-
- # Act - меняем имя сотрудника
- employee.first_name = 'Janet'
- employee.save()
-
- second_result = UpsertBirthdayEventService.execute({'employee': employee})
-
- # Assert
- assert second_result['success'] is True
- assert second_result['created'] is False # Обновлено, не создано
- assert second_result['event'].id == first_event_id # Тот же event
- assert second_result['event'].title == '🎂 День рождения: Janet Smith'
-
- def test_skip_employee_without_birth_date(self, create_test_employee):
- """Пропуск сотрудника без даты рождения."""
- # Arrange
- employee = create_test_employee(
- username='nobirthday',
- email='nobirthday@example.com',
- first_name='No',
- last_name='Birthday',
- birth_date=None
- )
-
- # Act
- result = UpsertBirthdayEventService.execute({'employee': employee})
-
- # Assert
- assert result['success'] is False
- assert result['reason'] == 'no_birth_date'
- assert result['event'] is None
-
- def test_creates_personal_calendar_if_not_exists(self, create_test_employee):
- """Автоматически создает личный календарь при первом событии."""
- # Arrange
- employee = create_test_employee(
- username='newuser',
- email='newuser@example.com',
- first_name='New',
- last_name='User',
- birth_date=date(1992, 7, 10)
- )
-
- # Убедимся, что календаря нет
- assert not Calendar.objects.filter(slug=f'employee-{employee.pk}').exists()
-
- # Act
- result = UpsertBirthdayEventService.execute({'employee': employee})
-
- # Assert
- assert result['success'] is True
- calendar = Calendar.objects.get(slug=f'employee-{employee.pk}')
- assert calendar.name == '👤 Мой календарь'
- assert result['event'].calendar == calendar
-
- def test_yearly_rule_reused_across_employees(self, create_test_employee):
- """Правило 'Ежегодно' переиспользуется для всех событий."""
- # Arrange
- employee1 = create_test_employee(
- username='user1',
- email='user1@example.com',
- first_name='User',
- last_name='One',
- birth_date=date(1990, 1, 1)
- )
- employee2 = create_test_employee(
- username='user2',
- email='user2@example.com',
- first_name='User',
- last_name='Two',
- birth_date=date(1991, 2, 2)
- )
-
- # Act
- result1 = UpsertBirthdayEventService.execute({'employee': employee1})
- result2 = UpsertBirthdayEventService.execute({'employee': employee2})
-
- # Assert
- assert result1['event'].rule.id == result2['event'].rule.id
- assert Rule.objects.filter(name='Ежегодно').count() == 1
-
-
-@pytest.mark.django_db
-class TestDeleteBirthdayEventService:
- """Тесты для сервиса удаления событий дня рождения."""
-
- def test_delete_existing_birthday_event(self, create_test_employee):
- """Удаление существующего события."""
- # Arrange
- employee = create_test_employee(
- username='todelete',
- email='todelete@example.com',
- first_name='To',
- last_name='Delete',
- birth_date=date(1988, 4, 25)
- )
-
- # Создаем событие
- UpsertBirthdayEventService.execute({'employee': employee})
- assert Event.objects.filter(creator_id=employee.pk).exists()
-
- # Act
- result = DeleteBirthdayEventService.execute({'employee': employee})
-
- # Assert
- assert result['success'] is True
- assert result['deleted_count'] == 1
- assert not Event.objects.filter(creator_id=employee.pk).exists()
-
- def test_delete_nonexistent_event_returns_false(self, create_test_employee):
- """Попытка удалить несуществующее событие."""
- # Arrange
- employee = create_test_employee(
- username='noevents',
- email='noevents@example.com',
- first_name='No',
- last_name='Events',
- birth_date=date(1995, 8, 30)
- )
-
- # Act
- result = DeleteBirthdayEventService.execute({'employee': employee})
-
- # Assert
- assert result['success'] is False
- assert result['deleted_count'] == 0
-
-
-@pytest.mark.django_db
-class TestBulkSyncBirthdaysService:
- """Тесты для массовой синхронизации дней рождений."""
-
- def test_bulk_sync_creates_events_for_all_employees(self, create_test_employee):
- """Массовая синхронизация создает события для всех сотрудников."""
- # Arrange
- employees_data = [
- ('user1', 'Alice', 'Johnson', date(1990, 1, 10)),
- ('user2', 'Bob', 'Williams', date(1985, 5, 20)),
- ('user3', 'Charlie', 'Brown', date(1992, 9, 15)),
- ]
-
- for username, first, last, birth in employees_data:
- create_test_employee(
- username=username,
- email=f'{username}@example.com',
- first_name=first,
- last_name=last,
- birth_date=birth
- )
-
- # Act
- result = BulkSyncBirthdaysService.execute({})
-
- # Assert
- assert result['total'] == 3
- assert result['created'] == 3
- assert result['updated'] == 0
- assert result['skipped'] == 0
- assert len(result['errors']) == 0
-
- # Проверяем, что события созданы
- assert Event.objects.filter(title__startswith='🎂 День рождения:').count() == 3
-
- def test_bulk_sync_updates_existing_events(self, create_test_employee):
- """Массовая синхронизация обновляет существующие события."""
- # Arrange
- employee = create_test_employee(
- username='existing',
- email='existing@example.com',
- first_name='Existing',
- last_name='User',
- birth_date=date(1987, 6, 12)
- )
-
- # Создаем событие заранее
- UpsertBirthdayEventService.execute({'employee': employee})
-
- # Act
- result = BulkSyncBirthdaysService.execute({})
-
- # Assert
- assert result['total'] == 1
- assert result['created'] == 0
- assert result['updated'] == 1
- assert result['skipped'] == 0
-
- def test_bulk_sync_skips_employees_without_birth_date(self, create_test_employee):
- """Пропускает сотрудников без даты рождения."""
- # Arrange
- create_test_employee(
- username='withbd',
- email='withbd@example.com',
- first_name='With',
- last_name='BirthDate',
- birth_date=date(1990, 1, 1)
- )
-
- create_test_employee(
- username='nobd',
- email='nobd@example.com',
- first_name='No',
- last_name='BirthDate',
- birth_date=None
- )
-
- # Act
- result = BulkSyncBirthdaysService.execute({})
-
- # Assert
- assert result['total'] == 2
- assert result['created'] == 1
- assert result['skipped'] == 1
-
- def test_bulk_sync_handles_errors_gracefully(self, create_test_employee):
- """Обрабатывает ошибки без падения всего процесса."""
- # Arrange
- create_test_employee(
- username='valid',
- email='valid@example.com',
- first_name='Valid',
- last_name='Employee',
- birth_date=date(1993, 3, 5)
- )
-
- # Act
- result = BulkSyncBirthdaysService.execute({})
-
- # Assert
- assert result['total'] >= 1
- assert result['created'] + result['updated'] >= 1
- # Часть может быть успешной даже если есть ошибки
diff --git a/backend/employees/urls_front.py b/backend/employees/urls_front.py
deleted file mode 100644
index e9fb2b6f..00000000
--- a/backend/employees/urls_front.py
+++ /dev/null
@@ -1,85 +0,0 @@
-# backend/employees/urls_front.py
-from django.urls import path
-
-from . import views_front
-
-app_name = "employees"
-
-urlpatterns = [
- # --- Профиль и сотрудники ---
- path("list/", views_front.employee_list, name="employees_list"),
- path("me/", views_front.employee_profile, name="profile"),
- path("me/edit/", views_front.employee_edit_me, name="employee_edit_me"),
- path("/", views_front.employee_profile, name="employee_detail"),
- path("/edit/", views_front.employee_edit, name="employee_edit"),
- path("create/", views_front.employee_create, name="employee_create"),
- path(
- "create/modal/",
- views_front.employee_create_modal,
- name="employee_create_modal"
- ),
-
- # --- Группы ---
- path("employees//groups/bulk/", views_front.employee_groups_bulk, name="employee_groups_bulk"),
- path("groups/create/", views_front.group_create, name="group_create"),
- path("groups/delete/", views_front.group_delete, name="group_delete"),
- # --- Отделы ---
- path("departments/create/", views_front.department_create, name="department_create"),
- path(
- "departments//roles/edit/",
- views_front.edit_department_role,
- name="edit_department_role",
- ),
- path("departments/", views_front.department_list, name="department_list"),
- path(
- "departments//", views_front.department_detail, name="department_detail"
- ),
- path(
- "departments//edit/",
- views_front.department_edit,
- name="department_edit",
- ),
- path(
- "departments//set-head/",
- views_front.department_set_head,
- name="department_set_head",
- ),
- path(
- "/departments//set-role/",
- views_front.set_member_role,
- name="set_member_role",
- ),
- path(
- "departments//add-member/",
- views_front.department_add_member,
- name="department_add_member",
- ),
- path(
- "departments//remove-member/",
- views_front.department_remove_member,
- name="department_remove_member",
- ),
- # --- Навыки ---
- path("/skill/add/", views_front.skill_add, name="skill_add"),
- path("/skill/remove/", views_front.skill_remove, name="skill_remove"),
- # --- Должности ---
- path(
- "positions/create/", views_front.position_create_front, name="position_create"
- ),
- path(
- "positions//update/",
- views_front.position_update_front,
- name="position_update",
- ),
- path(
- "/set-position/",
- views_front.employee_set_position_front,
- name="employee_set_position",
- ),
- path(
- "me/set-position/",
- views_front.employee_set_position_me_front,
- name="employee_set_position_me",
- ),
- path("positions//", views_front.position_detail_front, name="position_detail"),
-]
diff --git a/backend/employees/urls_front_auth.py b/backend/employees/urls_front_auth.py
deleted file mode 100644
index 62035775..00000000
--- a/backend/employees/urls_front_auth.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# backend\eusrr_backend\urls_front_auth.py
-from django.urls import path
-
-from . import views_auth
-
-app_name = "auth_front"
-
-urlpatterns = [
- path("login/", views_auth.EmailOrPhoneLoginView.as_view(), name="login"),
- path("logout/", views_auth.ConfirmLogoutView.as_view(), name="logout"),
- path("register/", views_auth.register_view, name="register"),
- path("verify-email/", views_auth.verify_email_view, name="verify_email"),
- path("resend-email/", views_auth.resend_email_view, name="resend_email"),
- path("password-reset/", views_auth.PasswordResetView.as_view(), name="password_reset"),
- path("password-reset/done/", views_auth.PasswordResetDoneView.as_view(), name="password_reset_done"),
- path("reset///", views_auth.PasswordResetConfirmView.as_view(), name="password_reset_confirm"),
- path("reset/done/", views_auth.PasswordResetCompleteView.as_view(), name="password_reset_complete"),
-]
\ No newline at end of file
diff --git a/backend/employees/views_auth.py b/backend/employees/views_auth.py
deleted file mode 100644
index 1a870920..00000000
--- a/backend/employees/views_auth.py
+++ /dev/null
@@ -1,256 +0,0 @@
-from __future__ import annotations
-
-import logging
-from api.client import ApiClient, get_api_client
-from django.contrib import messages
-from django.contrib.auth import logout
-from django.contrib.auth.mixins import LoginRequiredMixin
-from django.contrib.auth.views import LoginView as DjangoLoginView
-from django.contrib.auth.views import \
- PasswordResetCompleteView as DjangoPasswordResetCompleteView
-from django.contrib.auth.views import \
- PasswordResetConfirmView as DjangoPasswordResetConfirmView
-from django.contrib.auth.views import \
- PasswordResetDoneView as DjangoPasswordResetDoneView
-from django.contrib.auth.views import \
- PasswordResetView as DjangoPasswordResetView
-from django.shortcuts import redirect, render
-from django.urls import reverse
-from django.utils.http import urlencode
-from django.views import View
-from django.views.decorators.http import require_http_methods
-
-from .forms_auth import (EmailOrPhoneAuthenticationForm, RegistrationForm,
- VerifyEmailForm, CustomPasswordResetForm)
-
-logger = logging.getLogger(__name__)
-
-
-def _apply_api_errors_to_form(form, data: dict):
- """Развешиваем ошибки из API по полям/неполям."""
- if not isinstance(data, dict):
- form.add_error(None, "Неизвестная ошибка сервера.")
- return
-
- if "ok" in data and not data.get("ok"):
- form.add_error(
- None, data.get("error") or data.get("detail") or "Ошибка запроса."
- )
-
- detail = data.get("detail")
- if isinstance(detail, str):
- form.add_error(None, detail)
- elif isinstance(detail, (list, tuple)):
- for msg in detail:
- form.add_error(None, msg)
-
- for field, errs in data.items():
- if field in {"ok", "error", "detail"}:
- continue
- target = "phone_number" if field == "phone" else field
- if target in form.fields:
- if isinstance(errs, (list, tuple)):
- for e in errs:
- form.add_error(target, e)
- else:
- form.add_error(target, str(errs))
- else:
- if isinstance(errs, (list, tuple)):
- for e in errs:
- form.add_error(None, f"{field}: {e}")
- else:
- form.add_error(None, f"{field}: {errs}")
-
-
-@require_http_methods(["GET", "POST"])
-def register_view(request):
- """
- Страница регистрации: показываем Django-форму и шлём валидные данные в /api/v1/auth/register/
- (аватар пока локально, в API не отправляем — загрузим после верификации).
- """
- api = get_api_client(request)
-
- if request.method == "GET":
- form = RegistrationForm()
- return render(request, "auth/register.html", {"form": form})
-
- # ВАЖНО: учитывать файлы (чтобы form.avatar был BoundField)
- form = RegistrationForm(request.POST, request.FILES)
-
- # Логируем полученные данные
- logger.warning(f"[REGISTER_VIEW] POST data: {request.POST.dict()}")
- logger.warning(f"[REGISTER_VIEW] FILES: {list(request.FILES.keys())}")
-
- if not form.is_valid():
- logger.warning(f"[REGISTER_VIEW] Form errors: {form.errors}")
- return render(request, "auth/register.html", {"form": form}, status=400)
-
- payload = form.to_api_payload() # формирует json для API, включая password
- logger.warning(f"[REGISTER_VIEW] API payload keys: {list(payload.keys())}")
- payload_debug = {k: v for k, v in payload.items() if k != 'avatar'}
- logger.warning(f"[REGISTER_VIEW] API payload (без avatar): {payload_debug}")
- logger.warning(f"[REGISTER_VIEW] API base URL: {api.cfg.base_url}")
-
- resp = api.post("v1/auth/register/", json=payload)
- logger.warning(f"[REGISTER_VIEW] API response status: {resp.status}")
- resp_data = resp.json if resp.json else resp.text
- logger.warning(f"[REGISTER_VIEW] API response: {resp_data}")
-
- if resp.status == 201:
- messages.success(
- request,
- "Мы отправили код подтверждения на вашу почту. Введите его на следующем шаге.",
- )
- q = urlencode({"email": payload["email"]})
- return redirect(f'{reverse("auth_front:verify_email")}?{q}')
-
- data = resp.json or {}
- if resp.status == 200 and data.get("ok") and data.get("pending_verification"):
- messages.info(request, "Аккаунт уже создан, подтвердите email кодом из письма.")
- q = urlencode({"email": payload["email"]})
- return redirect(f'{reverse("auth_front:verify_email")}?{q}')
-
- # Остальные ошибки — показать в форме
- _apply_api_errors_to_form(form, data)
- return render(request, "auth/register.html", {"form": form}, status=400)
-
-
-@require_http_methods(["GET", "POST"])
-def verify_email_view(request):
- """Ввод кода подтверждения. Использует /api/v1/auth/verify-email/."""
- api = get_api_client(request)
-
- if request.method == "GET":
- form = VerifyEmailForm(initial={"email": request.GET.get("email", "")})
- return render(request, "auth/verify_email.html", {"form": form})
-
- form = VerifyEmailForm(request.POST)
- if not form.is_valid():
- return render(request, "auth/verify_email.html", {"form": form}, status=400)
-
- payload = {"email": form.cleaned_data["email"], "code": form.cleaned_data["code"]}
- r = api.post("v1/auth/verify-email/", json=payload)
-
- if r.ok and (r.json or {}).get("ok"):
- messages.success(request, "Email подтверждён! Теперь вы можете войти.")
- return redirect("auth_front:login")
-
- data = r.json or {}
- err = data.get("error") or data.get("detail") or "Не удалось подтвердить email."
- if err == "invalid_code":
- err = "Неверный код подтверждения."
- elif err == "user_not_found":
- err = "Пользователь с таким email не найден."
- elif err == "ldap_record_missing":
- err = "Ошибка синхронизации с LDAP. Обратитесь к администратору."
- form.add_error(None, err)
- return render(request, "auth/verify_email.html", {"form": form}, status=400)
-
-
-@require_http_methods(["POST"])
-def resend_email_view(request):
- """Кнопка «Отправить код ещё раз» — /api/v1/auth/resend-email/."""
- api = get_api_client(request)
- email = (request.POST.get("email") or "").strip()
- r = api.post("v1/auth/resend-email/", json={"email": email})
-
- if r.ok and (r.json or {}).get("ok"):
- messages.success(request, "Код подтверждения повторно отправлен на вашу почту.")
- else:
- data = r.json or {}
- err = (
- data.get("error")
- or data.get("detail")
- or "Не удалось отправить код повторно."
- )
- if err == "already_verified":
- err = "Email уже подтверждён."
- elif err == "user_not_found":
- err = "Пользователь с таким email не найден."
- messages.error(request, err)
-
- q = urlencode({"email": email}) if email else ""
- return redirect(f'{reverse("auth_front:verify_email")}{("?" + q) if q else ""}')
-
-
-class EmailOrPhoneLoginView(DjangoLoginView):
- """
- Страница входа: одно поле 'Email или телефон' + пароль.
- После успешной Django-аутентификации — берём JWT у DRF и кладём в сессию.
- """
-
- template_name = "auth/login.html"
- authentication_form = EmailOrPhoneAuthenticationForm
-
- def form_valid(self, form):
- # 1) Django-логин
- response = super().form_valid(form)
- # 2) Получаем JWT от API и кладём его в сессию
- user = self.request.user
- password = form.cleaned_data.get("password")
- client = ApiClient(self.request)
- ok = client.login(user.email, password) # USERNAME_FIELD=email
- if not ok:
- messages.error(
- self.request, "Не удалось получить токен API. Повторите вход позже."
- )
- return response
-
-
-class ConfirmLogoutView(LoginRequiredMixin, View):
- template_name = "auth/logout.html"
-
- def get(self, request):
- next_url = request.GET.get("next") or request.META.get("HTTP_REFERER") or ""
- return render(request, self.template_name, {"next": next_url})
-
- def post(self, request):
- next_url = request.POST.get("next") or reverse("auth_front:login")
- logout(request)
- messages.success(request, "Вы вышли из аккаунта.")
- return redirect(next_url)
-
-
-# ===== Сброс пароля =====
-
-
-class PasswordResetView(DjangoPasswordResetView):
- template_name = "auth/password_reset.html"
- email_template_name = "auth/password_reset_email.txt"
- html_email_template_name = "emails/password_reset_email.html"
- subject_template_name = "auth/password_reset_subject.txt"
- success_url = "/auth/password-reset/done/"
- form_class = CustomPasswordResetForm
-
- def post(self, request, *args, **kwargs):
- """Переопределяем post для логирования"""
- logger.info(
- f"\n{'='*80}\n"
- f"[PasswordResetView.post] 🔑 ЗАПРОС НА ВОССТАНОВЛЕНИЕ ПАРОЛЯ\n"
- f" Email/телефон: {request.POST.get('email', '?')}\n"
- f" IP: {request.META.get('REMOTE_ADDR')}\n"
- f"{'='*80}"
- )
-
- return super().post(request, *args, **kwargs)
-
- def form_valid(self, form):
- """Логирование успешной валидации формы"""
- logger.info(
- f"[PasswordResetView.form_valid] ✅ Форма валидна, email={form.cleaned_data.get('email')}"
- )
-
- return super().form_valid(form)
-
-
-class PasswordResetDoneView(DjangoPasswordResetDoneView):
- template_name = "auth/password_reset_done.html"
-
-
-class PasswordResetConfirmView(DjangoPasswordResetConfirmView):
- template_name = "auth/password_reset_confirm.html"
- success_url = "/auth/reset/done/"
-
-
-class PasswordResetCompleteView(DjangoPasswordResetCompleteView):
- template_name = "auth/password_reset_complete.html"
diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py
deleted file mode 100644
index 83e40230..00000000
--- a/backend/employees/views_front.py
+++ /dev/null
@@ -1,1741 +0,0 @@
-# backend/employees/views_front.py
-
-from __future__ import annotations
-
-import base64
-import json
-import mimetypes
-from io import BytesIO
-from typing import Any, Dict, Tuple
-
-from api.client import get_api_client
-from api.decorators import require_api_auth
-from django.contrib import messages
-from django.contrib.auth.decorators import login_required
-from django.http import HttpRequest, JsonResponse
-from django.http.multipartparser import MultiPartParser, MultiPartParserError
-from django.shortcuts import redirect, render
-from django.urls import reverse
-from django.views.decorators.csrf import csrf_protect
-from django.views.decorators.http import require_http_methods, require_POST
-
-from .forms_front import DepartmentEditForm, SetHeadForm
-
-# =========================
-# Утилиты
-# =========================
-
-
-def _file_to_data_uri(uploaded) -> str:
- """
- Превращаем загруженный файл (InMemoryUploadedFile/TemporaryUploadedFile)
- в data URI (data:image/...;base64,xxxx).
- Deprecated: используйте прямую отправку файлов через multipart/form-data.
- """
- if not uploaded:
- return ""
- content = uploaded.read()
- if hasattr(uploaded, "seek"):
- uploaded.seek(0)
- mime = (
- getattr(uploaded, "content_type", None)
- or mimetypes.guess_type(getattr(uploaded, "name", ""))[0]
- or "application/octet-stream"
- )
- b64 = base64.b64encode(content).decode("ascii")
- return f"data:{mime};base64,{b64}"
-
-
-def _parse_multipart_request(
- request: HttpRequest, logger
-) -> tuple[Dict[str, Any], Any]:
- """Возвращает (POST, FILES) даже для PATCH с multipart/form-data."""
- content_type = request.content_type or ""
- if request.method == "POST" or "multipart/form-data" not in content_type:
- return request.POST, request.FILES
-
- try:
- stream = BytesIO(request.body)
- parser = MultiPartParser(
- request.META,
- stream,
- request.upload_handlers,
- request.encoding,
- )
- post_data, files_data = parser.parse()
- logger.info(
- "[multipart_parser] Parsed PATCH payload: %s fields, %s files",
- len(post_data.keys()),
- len(files_data.keys()),
- )
- return post_data, files_data
- except MultiPartParserError as exc:
- logger.error(
- "[multipart_parser] Failed to parse multipart body: %s",
- exc,
- )
- except Exception:
- logger.exception(
- "[multipart_parser] Unexpected error while parsing multipart body"
- )
- return request.POST, request.FILES
-
-
-def _extract_items(payload: Any):
- """
- Универсальный парсер: пагинированный ответ DRF или «сырой» список.
- """
- if isinstance(payload, dict) and "results" in payload:
- return (
- payload["results"],
- payload.get("count", 0),
- payload.get("next"),
- payload.get("previous"),
- )
- if isinstance(payload, list):
- return payload, len(payload), None, None
- 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 parse_qs, urlencode, urlparse
-
- 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 []
- return payload or []
-
-
-def _fetch_employee_posts(api, employee_id: int):
- # сначала пробуем ?author=, если в API другое имя фильтра — fallback на ?employee=
- for url in (
- f"v1/posts/?author={employee_id}&ordering=-created_at",
- f"v1/posts/?employee={employee_id}&ordering=-created_at",
- ):
- r = api.get(url)
- if r.ok:
- data = r.json or []
- return _extract_results(data)
-
-
-def _fetch_department_posts(api, department_id: int) -> list[dict]:
- """Возвращает публикации отдела (type=department), отсортированные по пину и дате.
-
- Args:
- api: Инициализированный API-клиент (get_api_client(request)).
- department_id (int): ID отдела.
-
- Returns:
- list[dict]: Список публикаций для шаблона.
-
- Raises:
- RuntimeError: Если ответ API не JSON (редко, мы перехватываем безопасно).
- ValueError: Если department_id некорректный (не int).
- """
- # Пробуем явный фильтр по типу и отделу; fallback — только department
- for url in (
- f"v1/posts/?type=department&department={int(department_id)}&ordering=-pinned,-created_at",
- f"v1/posts/?department={int(department_id)}&ordering=-pinned,-created_at",
- ):
- r = api.get(url)
- if r.ok:
- data = r.json or []
- return _extract_results(data)
- return []
-
-
-def _json_body(request: HttpRequest) -> Dict[str, Any]:
- """Безопасно парсим JSON; если не JSON — возвращаем пустой dict."""
- try:
- if request.body:
- return json.loads(request.body.decode("utf-8"))
- except Exception:
- pass
- return {}
-
-
-def _error(message: str, status: int = 400) -> JsonResponse:
- return JsonResponse({"detail": message}, status=status)
-
-
-def _ok(data: Dict[str, Any], status: int = 200) -> JsonResponse:
- return JsonResponse(data, status=status)
-
-
-def _api_ok_or_error(resp) -> Tuple[bool, Dict[str, Any], int]:
- """Унифицированная распаковка ответа API."""
- try:
- data = resp.json or {}
- except Exception:
- data = {}
- return resp.ok, data, getattr(resp, "status", 500)
-
-
-# =========================
-# Сотрудники
-# =========================
-
-
-# ---------- LIST ----------
-@require_api_auth
-def employee_list(request):
- """
- Список сотрудников.
- Если ?format=json — вернуть JSON (для автокомплита):
- /employees/list/?format=json&q=...&exclude=1,2,3&limit=12
-
- Для HTML страницы - данные загружаются через JavaScript на клиенте.
- """
- # Если параметр active отсутствует и это HTML-запрос,
- # редиректим с active=true по умолчанию
- if request.GET.get("format") != "json" and "active" not in request.GET:
- params = request.GET.copy()
- params["active"] = "true"
- from django.http import QueryDict
-
- query_string = params.urlencode()
- return redirect(f"{request.path}?{query_string}")
-
- api = get_api_client(request)
-
- # JSON-ветка для автокомплита
- if request.GET.get("format") == "json":
- page = request.GET.get("page") or 1
- ordering = request.GET.get("o") or "last_name"
- q = (request.GET.get("q") or "").strip()
- department = request.GET.get("department")
- position = request.GET.get("position")
- is_active = request.GET.get("is_active")
-
- params = {"page": page, "ordering": ordering}
- if q:
- params["search"] = q
- if department:
- params["department"] = department
- if position:
- params["position"] = position
- if is_active:
- params["is_active"] = is_active
-
- resp = api.get("v1/employees/", params=params)
- if not resp.ok:
- return JsonResponse({"results": [], "count": 0}, status=resp.status or 500)
-
- payload = resp.json or {}
- items, count, next_url, prev_url = _extract_items(payload)
-
- # exclude: "1,2,3"
- raw_ex = (request.GET.get("exclude") or "").strip()
- try:
- ex_ids = {int(x) for x in raw_ex.split(",") if x.strip().isdigit()}
- except Exception:
- ex_ids = set()
-
- try:
- limit = int(request.GET.get("limit") or 12)
- except Exception:
- limit = 12
-
- # отфильтруем и обрежем на стороне фронт-прокси
- def _id(e):
- try:
- return int(e.get("id"))
- except Exception:
- return -1
-
- items = [e for e in items if _id(e) not in ex_ids][:limit]
- return JsonResponse({"results": items, "count": len(items)})
-
- # HTML-ветка - НЕ загружаем данные сотрудников, они загрузятся через JavaScript
- # Получаем только справочники для фильтров
- departments_list = []
- positions_list = []
- try:
- # Получаем все отделы для фильтров
- dept_resp = api.get("v1/departments/", params={"page_size": 1000})
- if dept_resp.ok:
- dept_data = dept_resp.json or {}
- departments_list = dept_data.get("results", [])
-
- # Получаем все должности для фильтров
- pos_resp = api.get("v1/positions/", params={"page_size": 1000})
- if pos_resp.ok:
- pos_data = pos_resp.json or {}
- positions_list = pos_data.get("results", [])
- except Exception:
- pass
-
- context = {
- "employees": [], # Пустой список - данные загрузятся через JS
- "departments_list": departments_list,
- "positions_list": positions_list,
- }
- return render(request, "employees/employees_list.html", context)
-
-
-# ---------- DETAIL / ME ----------
-@require_api_auth
-def employee_profile(request, pk: str | int = "me"):
- """Профиль сотрудника (/employees/me/ и /employees//).
-
- Поведение:
- * Если pk совпадает с текущим пользователем — выполняется 302 редирект на /employees/me/?.
- * Подтягивает данные сотрудника, посты, справочники (навыки/должности/группы).
- * Передаёт в шаблон персональные группы сотрудника для рендера «пилюль».
- * Передаёт `endpoint` для PATCH-редактирования профиля через фронтовую вьюху.
-
- Args:
- request (HttpRequest): HTTP-запрос.
- pk (str | int, optional): 'me'/'self' или числовой ID сотрудника. По умолчанию 'me'.
-
- Returns:
- HttpResponse: Отрисованный шаблон профиля либо редирект/сообщение об ошибке.
-
- Raises:
- None: Все ошибки бэкенд-API обрабатываются и отражаются через messages/redirect.
- """
- # --- редирект "свой id" -> /me ---
- try:
- int_pk = int(pk) # type: ignore[arg-type]
- except (TypeError, ValueError):
- int_pk = None
-
- if (
- int_pk is not None
- and request.user.is_authenticated
- and request.user.id == int_pk
- ):
- url = reverse("employees:profile")
- qs = request.META.get("QUERY_STRING")
- if qs: # сохраняем ?tab=skills и т.п.
- url = f"{url}?{qs}"
- return redirect(url) # 302 достаточно, чтобы не кэшировать навсегда
-
- # --- обычная логика detail/me ---
- api = get_api_client(request)
- is_me = str(pk) in {"me", "self"}
-
- endpoint_api = "v1/employees/me/" if is_me else f"v1/employees/{pk}/"
- resp = api.get(endpoint_api)
- if not resp.ok:
- messages.error(request, f"Сотрудник не найден ({resp.status})")
- return redirect("employees:employees_list")
-
- emp = resp.json or {}
- emp_id = emp.get("id") or (request.user.id if is_me else pk)
- posts = _fetch_employee_posts(api, emp_id)
-
- # Справочники
- sresp = api.get("v1/skills/", params={"ordering": "name"})
- sdata = sresp.json if sresp.ok else []
- all_skills = sdata.get("results", sdata) if isinstance(sdata, dict) else sdata
-
- gresp_all = api.get("v1/groups/", params={"ordering": "name"})
- gdata_all = gresp_all.json if gresp_all.ok else []
- all_groups = (
- gdata_all.get("results", gdata_all)
- if isinstance(gdata_all, dict)
- else gdata_all
- ) or []
-
- # Персональные группы сотрудника (для «пилюль»)
- pg_resp = api.get("v1/groups/", params={"ordering": "name", "member": emp_id})
- pg_data = pg_resp.json if pg_resp.ok else []
- emp_personal_groups = (
- pg_data.get("results", pg_data) if isinstance(pg_data, dict) else pg_data
- ) or []
-
- presp = api.get("v1/positions/", params={"ordering": "name"})
- pdata = presp.json if presp.ok else []
- all_positions = pdata.get("results", pdata) if isinstance(pdata, dict) else pdata
-
- # Отделы/роли
- emp_depts = []
- for d in emp.get("departments") or []:
- did = d.get("id")
- if not did:
- emp_depts.append(d)
- continue
-
- # узнаём head отдела
- dresp = api.get(f"v1/departments/{did}/")
- head_id = None
- if dresp.ok:
- dj = dresp.json or {}
- head = dj.get("head") or {}
- head_id = head.get("id") or dj.get("head_id")
-
- # подгружаем роли отдела
- rresp = api.get(
- "v1/department-roles/", params={"department": did, "ordering": "name"}
- )
- roles = []
- if rresp.ok:
- rj = rresp.json
- roles = (rj.get("results", rj) if isinstance(rj, dict) else rj) or []
-
- can_edit_role = (
- bool(head_id)
- and str(head_id) == str(getattr(request.user, "id", None))
- and not d.get("is_head") # нельзя править главу
- and str(emp_id) != str(getattr(request.user, "id", None)) # не себе
- )
-
- d2 = dict(d)
- d2.update(
- {
- "roles_choices": roles, # [{id, name, ...}]
- "can_edit_role": can_edit_role, # bool
- }
- )
- emp_depts.append(d2)
-
- # Права на редактирование профиля
- can_edit = (
- True
- if is_me
- else bool(
- request.user and (request.user.is_staff or request.user.id == emp.get("id"))
- )
- )
-
- # ВАЖНО: endpoint фронтовой вьюхи для PATCH профиля (а не прямой DRF-URL)
- endpoint = (
- reverse("employees:employee_edit_me")
- if is_me
- else reverse("employees:employee_edit", args=[emp_id])
- )
-
- ctx = {
- "emp": emp,
- "emp_depts": emp_depts,
- "can_edit": can_edit,
- "all_skills": all_skills,
- "all_positions": all_positions,
- "all_groups": all_groups,
- "emp_personal_groups": emp_personal_groups,
- "posts": posts,
- "is_me": True if is_me else False,
- "endpoint": endpoint, # для data-endpoint в _employee_edit.html
- }
- return render(request, "employees/employee_detail.html", ctx)
-
-
-# ---------- EDIT (self or staff) ----------
-@require_api_auth
-@require_http_methods(["PATCH", "POST"])
-def employee_edit(request, pk: int):
- """
- Редактирование пользователя по id.
- Поддерживает как JSON, так и multipart/form-data (для загрузки файлов).
- """
- import logging
-
- logger = logging.getLogger(__name__)
-
- api = get_api_client(request)
-
- # Проверяем Content-Type
- content_type = request.content_type or ""
- logger.info(f"[employee_edit] pk={pk}, Content-Type: {content_type}")
- parsed_post, parsed_files = _parse_multipart_request(request, logger)
- logger.info(
- "[employee_edit] request.POST keys: %s",
- list(parsed_post.keys()),
- )
- logger.info(
- "[employee_edit] request.FILES keys: %s",
- list(parsed_files.keys()),
- )
-
- if "multipart/form-data" in content_type:
- # Обрабатываем multipart/form-data (файлы)
- logger.info("[employee_edit] Processing multipart/form-data")
- payload = {}
-
- # Собираем обычные поля из POST
- for key, value in parsed_post.items():
- if key == "csrfmiddlewaretoken":
- continue
- # Если это массив (например skills_ids)
- if key.endswith("_ids") or key == "skills":
- payload[key] = parsed_post.getlist(key)
- else:
- payload[key] = value
-
- logger.info(
- "[employee_edit] Payload before avatar: %s",
- list(payload.keys()),
- )
-
- # Обрабатываем файлы (аватар)
- if "avatar" in parsed_files:
- avatar_file = parsed_files["avatar"]
- logger.info(
- "[employee_edit] Processing avatar: %s, size: %s",
- avatar_file.name,
- avatar_file.size,
- )
- else:
- logger.warning("[employee_edit] No avatar in request.FILES")
-
- logger.info(f"[employee_edit] Final payload keys: {list(payload.keys())}")
-
- # Отправляем в DRF (multipart если есть файл, иначе JSON)
- try:
- if avatar_file:
- files = {
- "avatar": (
- avatar_file.name,
- avatar_file.read(),
- avatar_file.content_type,
- )
- }
- resp = api.patch(f"v1/employees/{pk}/", data=payload, files=files)
- else:
- resp = api.patch(f"v1/employees/{pk}/", json=payload)
- logger.info(f"[employee_edit] API response status: {resp.status}")
- except Exception as e:
- logger.error(f"[employee_edit] API call error: {e}")
- return JsonResponse({"detail": f"API error: {str(e)}"}, status=500)
- else:
- # Обрабатываем JSON (как раньше)
- logger.info("[employee_edit] Processing JSON")
- try:
- payload = json.loads(request.body or b"{}")
- except Exception as e:
- logger.error(f"[employee_edit] JSON parse error: {e}")
- payload = {}
-
- try:
- resp = api.patch(f"v1/employees/{pk}/", json=payload)
- except Exception as e:
- logger.error(f"[employee_edit] API call error: {e}")
- return JsonResponse({"detail": f"API error: {str(e)}"}, status=500)
-
- try:
- data = resp.json
- except Exception as e:
- logger.error(f"[employee_edit] Response JSON parse error: {e}")
- data = {"detail": resp.text}
-
- # Всегда возвращаем JSON без редиректа
- return JsonResponse(data or {}, status=resp.status)
-
-
-# ---------- EDIT ME ----------
-@require_api_auth
-@require_http_methods(["PATCH", "POST"])
-def employee_edit_me(request):
- """
- Редактирование собственного профиля через /api/v1/employees/me/
- Поддерживает как JSON, так и multipart/form-data (для загрузки файлов).
- """
- import logging
-
- logger = logging.getLogger(__name__)
-
- logger.info("=" * 80)
- logger.info("[employee_edit_me] ===== FUNCTION CALLED =====")
- logger.info(f"[employee_edit_me] Method: {request.method}")
-
- api = get_api_client(request)
-
- # Проверяем Content-Type
- content_type = request.content_type or ""
- logger.info(f"[employee_edit_me] Content-Type: {content_type}")
- parsed_post, parsed_files = _parse_multipart_request(request, logger)
- logger.info(
- "[employee_edit_me] request.POST keys: %s",
- list(parsed_post.keys()),
- )
- logger.info(
- "[employee_edit_me] request.FILES keys: %s",
- list(parsed_files.keys()),
- )
-
- if "multipart/form-data" in content_type:
- # Обрабатываем multipart/form-data (файлы)
- logger.info("[employee_edit_me] Processing multipart/form-data")
- payload = {}
-
- # Собираем обычные поля из POST
- for key, value in parsed_post.items():
- value_len = len(value) if hasattr(value, "__len__") else "N/A"
- logger.info(
- f"[employee_edit_me] POST field: '{key}' = '{value}' "
- f"(type: {type(value).__name__}, len: {value_len})"
- )
- if key == "csrfmiddlewaretoken":
- continue
- # Пропускаем пустое поле avatar (приходит из FormData без файла)
- if key == "avatar" and value == "":
- logger.info("[employee_edit_me] ✅ Skipping empty avatar field")
- continue
- # Конвертируем пустые строки в null для ForeignKey полей
- if key.endswith("_id") and value == "":
- value = None
- # Если это массив (например skills_ids)
- if key.endswith("_ids") or key == "skills":
- payload[key] = parsed_post.getlist(key)
- else:
- payload[key] = value
-
- logger.info(
- "[employee_edit_me] Payload before avatar: %s",
- list(payload.keys()),
- )
-
- # Обрабатываем файлы (аватар)
- if "avatar" in parsed_files:
- avatar_file = parsed_files["avatar"]
- logger.info(
- "[employee_edit_me] Processing avatar: %s, size: %s",
- avatar_file.name,
- avatar_file.size,
- )
- else:
- logger.warning("[employee_edit_me] No avatar in request.FILES")
-
- logger.info(
- "[employee_edit_me] Final payload keys: %s",
- list(payload.keys()),
- )
-
- # Отправляем в DRF (multipart если есть файл, иначе JSON)
- try:
- if avatar_file:
- files = {
- "avatar": (
- avatar_file.name,
- avatar_file.read(),
- avatar_file.content_type,
- )
- }
- resp = api.patch("v1/employees/me/", data=payload, files=files)
- else:
- resp = api.patch("v1/employees/me/", json=payload)
- logger.info(
- "[employee_edit_me] API response status: %s",
- resp.status,
- )
- except Exception as e:
- logger.error(f"[employee_edit_me] API call error: {e}")
- return JsonResponse({"detail": f"API error: {str(e)}"}, status=500)
- else:
- # Обрабатываем JSON (как раньше)
- logger.info("[employee_edit_me] Processing JSON")
- try:
- payload = json.loads(request.body or b"{}")
- except Exception as e:
- logger.error(f"[employee_edit_me] JSON parse error: {e}")
- payload = {}
-
- try:
- resp = api.patch("v1/employees/me/", json=payload)
- except Exception as e:
- logger.error(f"[employee_edit_me] API call error: {e}")
- return JsonResponse({"detail": f"API error: {str(e)}"}, status=500)
-
- try:
- data = resp.json
- except Exception as e:
- logger.error(f"[employee_edit_me] Response JSON parse error: {e}")
- data = {"detail": resp.text}
-
- # Всегда возвращаем JSON без редиректа
- return JsonResponse(data or {}, status=resp.status)
-
-
-# ---------- CREATE (staff) ----------
-@require_api_auth
-@require_http_methods(["GET", "POST"])
-def employee_create(request):
- """Страница создания сотрудника (старый вариант)"""
- api = get_api_client(request)
-
- if request.method == "GET":
- return render(request, "employees/employee_create.html")
-
- data = {
- "email": request.POST.get("email", "").strip(),
- "phone_number": request.POST.get("phone_number", "").strip(),
- "last_name": request.POST.get("last_name", "").strip(),
- "first_name": request.POST.get("first_name", "").strip(),
- "patronymic": request.POST.get("patronymic", "").strip(),
- "telegram": request.POST.get("telegram", "").strip(),
- "whatsapp": request.POST.get("whatsapp", "").strip(),
- "wechat": request.POST.get("wechat", "").strip(),
- }
-
- # Обработка gender (должно быть числом)
- gender = request.POST.get("gender", "").strip()
- if gender:
- try:
- data["gender"] = int(gender)
- except (ValueError, TypeError):
- pass
-
- # Обработка birth_date
- birth_date = request.POST.get("birth_date", "").strip()
- if birth_date:
- data["birth_date"] = birth_date
-
- # Обработка position
- position = request.POST.get("position", "").strip()
- if position:
- try:
- data["position"] = int(position)
- except (ValueError, TypeError):
- pass
-
- skills = request.POST.getlist("skills")
- if skills:
- data["skills"] = skills
- password = request.POST.get("password", "").strip()
- if password:
- data["password"] = password # API сгенерирует сам, если не передали
-
- # Если есть аватар - multipart, иначе JSON
- files = {}
- if request.FILES.get("avatar"):
- avatar_file = request.FILES["avatar"]
- files["avatar"] = (
- avatar_file.name,
- avatar_file.read(),
- avatar_file.content_type,
- )
-
- if files:
- resp = api.post("v1/employees/", data=data, files=files)
- else:
- resp = api.post("v1/employees/", json=data)
- if not resp.ok:
- messages.error(request, f"Не удалось создать: {resp.status} — {resp.text}")
- return render(request, "employees/employee_create.html", {"form": request.POST})
-
- new_emp = resp.json or {}
- messages.success(request, "Сотрудник создан.")
- return redirect("employees:employee_detail", pk=new_emp.get("id"))
-
-
-@login_required
-@require_http_methods(["POST"])
-def employee_create_modal(request):
- """
- Обработчик создания сотрудника через модальное окно.
- Принимает AJAX запрос, обращается к API через фронтовую логику.
- Возвращает JSON с результатом.
- """
- api = get_api_client(request)
-
- # Собираем данные из формы
- data = {
- "email": request.POST.get("email", "").strip(),
- "phone_number": request.POST.get("phone_number", "").strip(),
- "last_name": request.POST.get("last_name", "").strip(),
- "first_name": request.POST.get("first_name", "").strip(),
- "patronymic": request.POST.get("patronymic", "").strip(),
- "telegram": request.POST.get("telegram", "").strip(),
- "whatsapp": request.POST.get("whatsapp", "").strip(),
- "wechat": request.POST.get("wechat", "").strip(),
- }
-
- # Обработка gender (должно быть числом: 0, 1, 2)
- gender = request.POST.get("gender", "").strip()
- if gender:
- try:
- data["gender"] = int(gender)
- except (ValueError, TypeError):
- data["gender"] = None
-
- # Обработка остальных полей
- birth_date = request.POST.get("birth_date", "").strip()
- if birth_date:
- data["birth_date"] = birth_date
-
- position = request.POST.get("position", "").strip()
- if position:
- try:
- data["position"] = int(position)
- except (ValueError, TypeError):
- pass
-
- # Обработка навыков (если будут добавлены в форму)
- skills = request.POST.getlist("skills")
- if skills:
- data["skills"] = skills
-
- # Обработка пароля
- password = request.POST.get("password", "").strip()
- if password:
- data["password"] = password
-
- # Если есть аватар - отправляем multipart, иначе JSON
- files = {}
- if request.FILES.get("avatar"):
- avatar_file = request.FILES["avatar"]
- files["avatar"] = (
- avatar_file.name,
- avatar_file.read(),
- avatar_file.content_type,
- )
-
- # Отправляем запрос к API
- if files:
- # С файлом - multipart/form-data
- resp = api.post("v1/employees/", data=data, files=files)
- else:
- # Без файла - JSON
- resp = api.post("v1/employees/", json=data)
-
- if not resp.ok:
- # Парсим ошибки от API
- error_msg = "Не удалось создать сотрудника"
- field_errors = {}
-
- try:
- error_data = resp.json
- if isinstance(error_data, dict):
- # Логируем для отладки
- import logging
-
- logger = logging.getLogger(__name__)
- logger.error(f"API validation errors: {error_data}, sent data: {data}")
-
- # Проверяем, есть ли детальные ошибки по полям
- for field, messages_list in error_data.items():
- if isinstance(messages_list, list):
- field_errors[field] = messages_list
- else:
- field_errors[field] = [str(messages_list)]
- except Exception as e:
- import logging
-
- logger = logging.getLogger(__name__)
- logger.error(f"Error parsing API response: {e}, resp.text: {resp.text}")
- error_msg = f"Ошибка {resp.status}: {resp.text}"
-
- # Если есть детальные ошибки, возвращаем их
- if field_errors:
- return JsonResponse(
- {
- "success": False,
- "error": "Проверьте правильность заполнения полей",
- "errors": field_errors,
- },
- status=400,
- )
- else:
- return JsonResponse({"success": False, "error": error_msg}, status=400)
-
- # Успешное создание
- new_emp = resp.json or {}
- emp_id = new_emp.get("id")
-
- redirect_url = None
- if emp_id:
- redirect_url = reverse("employees:employee_detail", kwargs={"pk": emp_id})
-
- return JsonResponse(
- {
- "success": True,
- "message": "Сотрудник успешно создан",
- "employee_id": emp_id,
- "redirect_url": redirect_url,
- }
- )
-
-
-# =========================
-# Группы
-# =========================
-
-
-@require_api_auth
-@require_http_methods(["POST"])
-def employee_groups_bulk(request, pk: int):
- """
- Назначить/снять персональные группы сотруднику.
- Body: { "action": "add"|"remove", "group_ids": [1,2,3] }
- """
- api = get_api_client(request)
- try:
- payload = json.loads(request.body or b"{}")
- except Exception:
- payload = {}
- action = (payload.get("action") or "").strip().lower()
- group_ids = payload.get("group_ids") or []
- if action not in {"add", "remove"}:
- return JsonResponse({"detail": "action must be 'add' or 'remove'"}, status=400)
- try:
- gids = [int(x) for x in group_ids]
- except Exception:
- return JsonResponse(
- {"detail": "group_ids must be a list of integers"}, status=400
- )
- if not gids:
- return JsonResponse({"detail": "group_ids is empty"}, status=400)
-
- ok, errors = 0, {}
- endpoint = "add-members" if action == "add" else "remove-members"
- for gid in gids:
- r = api.post(f"v1/groups/{gid}/{endpoint}/", json={"member_ids": [pk]})
- if r.ok:
- ok += 1
- else:
- try:
- j = r.json or {}
- msg = j.get("detail") or r.text or f"HTTP {r.status}"
- except Exception:
- msg = r.text or f"HTTP {r.status}"
- errors[str(gid)] = msg
- status_code = 200 if not errors else 207 # Multi-Status семантически уместен
- return JsonResponse(
- {"ok": True, "processed": ok, "failed": errors}, status=status_code
- )
-
-
-@require_api_auth
-@require_http_methods(["POST"])
-def group_create(request):
- """
- Создать группу (LDAP+БД) через DRF.
- Body: { "name": "CN-Name" }
- """
- api = get_api_client(request)
- try:
- payload = json.loads(request.body or b"{}")
- except Exception:
- payload = {}
- name = (payload.get("name") or "").strip()
- if not name:
- return JsonResponse({"detail": "name is required"}, status=400)
-
- resp = api.post("v1/groups/", json={"name": name})
- try:
- data = resp.json
- except Exception:
- data = {"detail": resp.text}
- return JsonResponse(data or {}, status=resp.status)
-
-
-@require_api_auth
-@require_http_methods(["POST"])
-def group_delete(request):
- """
- Удалить группу (LDAP+БД) через DRF.
- Body: { "group_id": 123 }
- """
- api = get_api_client(request)
- try:
- payload = json.loads(request.body or b"{}")
- except Exception:
- payload = {}
- gid = payload.get("group_id")
- try:
- gid = int(gid)
- except Exception:
- return JsonResponse({"detail": "group_id must be int"}, status=400)
-
- resp = api.delete(f"v1/groups/{gid}/")
- if not resp.ok:
- return JsonResponse({"detail": resp.text}, status=resp.status)
- return JsonResponse({"ok": True, "id": gid}, status=200)
-
-
-# =========================
-# Отделы
-# =========================
-
-
-# ---------- LIST ----------
-@require_api_auth
-def department_list(request):
- """
- Список отделов.
- Ходит в /api/v1/departments/ c поддержкой поиска/сортировки/пагинации DRF.
- """
- api = get_api_client(request)
-
- page = request.GET.get("page") or 1
- ordering = request.GET.get("o") or "name"
- q = (request.GET.get("q") or "").strip()
-
- params = {"page": page, "ordering": ordering}
- if q:
- params["search"] = q # DRF SearchFilter
-
- resp = api.get("v1/departments/", params=params)
- if not resp.ok:
- messages.error(request, f"Ошибка API ({resp.status})")
- return render(
- request,
- "employees/department_list.html",
- {
- "departments": [],
- "count": 0,
- "q": q,
- "o": ordering,
- "page": page,
- "next_url": None,
- "prev_url": None,
- },
- )
-
- payload = resp.json # важно: без "or {}" — иначе потеряем список
-
- # ✅ поддерживаем оба формата DRF: dict с results или list без пагинации
- if isinstance(payload, dict) and "results" in payload:
- items = payload["results"]
- count = payload.get("count", len(items))
- next_url = payload.get("next")
- prev_url = payload.get("previous")
- elif isinstance(payload, list):
- items = payload
- count = len(items)
- next_url = prev_url = None
- else:
- items = []
- 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,
- "q": q,
- "o": ordering,
- "page": page,
- "next_url": next_url,
- "prev_url": prev_url,
- }
- return render(request, "employees/department_list.html", context)
-
-
-# ---------- DETAIL ----------
-@require_api_auth
-def department_detail(request, pk: int):
- """
- Страница отдела.
-
- Тонкая фронт-вьюха: забирает агрегированный контекст у API
- /api/v1/departments/{id}/ui-context/, дополняет его минимальными
- метаданными для шаблона и рендерит страницу.
-
- В контекст добавляются:
- - members_exclude_ids: список id сотрудников, которых нельзя
- предлагать в "Добавить участника" (те, кто уже в отделе + руководитель)
- - can_manage / can_change_head / can_assign_roles: плоские флаги,
- чтобы шаблонам не ходить в вложенный user_perms.
- """
- api = get_api_client(request)
- r = api.get(f"v1/departments/{pk}/ui-context/")
- if not r.ok:
- messages.error(
- request,
- "Отдел не найден" if r.status == 404 else f"Ошибка API ({r.status})",
- )
- return redirect("employees:department_list")
-
- # r.json в нашем api-клиенте может быть полем или методом — поддержим оба варианта
- try:
- ctx = (
- r.json
- if isinstance(r.json, dict)
- else (r.json() if callable(r.json) else {})
- )
- except Exception:
- ctx = {}
-
- if not isinstance(ctx, dict):
- ctx = {}
-
- perms = (
- (ctx.get("user_perms") or {}) if isinstance(ctx.get("user_perms"), dict) else {}
- )
-
- # ——— метаданные для автодополнения «Добавить участника» ———
- links = ctx.get("links") or []
- exclude_ids: list[int] = []
- for li in links:
- emp = li.get("employee") if isinstance(li, dict) else None
- if emp is None:
- continue
- # в агрегате employee может быть int (id) или объект
- eid = emp.get("id") if isinstance(emp, dict) else emp
- if eid:
- try:
- exclude_ids.append(int(eid))
- except (TypeError, ValueError):
- pass
-
- # head тоже исключаем
- dept = ctx.get("dept") or {}
- head_id = dept.get("head_id") or (
- (dept.get("head") or {}).get("id")
- if isinstance(dept.get("head"), dict)
- else None
- )
- if head_id:
- try:
- exclude_ids.append(int(head_id))
- except (TypeError, ValueError):
- pass
-
- # уберём дубликаты, сохраняя порядок
- seen = set()
- exclude_ids = [x for x in exclude_ids if not (x in seen or seen.add(x))]
-
- ctx.update(
- {
- "members_exclude_ids": exclude_ids,
- "can_manage": bool(perms.get("can_manage")),
- "can_change_head": bool(perms.get("can_change_head")),
- "can_assign_roles": bool(perms.get("can_assign_roles")),
- "posts": _fetch_department_posts(api, pk),
- }
- )
-
- # Добавляем бюджет отдела на текущий квартал
- from django.utils import timezone
- from procurement.models import Budget
- now = timezone.now()
- current_quarter = (now.month - 1) // 3 + 1
- try:
- department_budget = Budget.objects.get(
- department_id=pk,
- year=now.year,
- quarter=current_quarter
- )
- except Budget.DoesNotExist:
- department_budget = None
-
- ctx["department_budget"] = department_budget
- ctx["budget_year"] = now.year
- ctx["budget_quarter"] = current_quarter
-
- return render(request, "employees/department_detail.html", ctx)
-
-
-# ---------- EDIT BASIC FIELDS ----------
-@require_api_auth
-@require_http_methods(["POST"])
-def department_edit(request, pk: int):
- """
- Редактирование name/description через PATCH /api/v1/departments/{id}/.
- Права проверяет API (manage_department).
- """
- api = get_api_client(request)
- form = DepartmentEditForm(request.POST)
- if not form.is_valid():
- messages.error(request, "Проверьте форму")
- return redirect("employees:department_detail", pk=pk)
-
- payload = {
- "name": form.cleaned_data["name"],
- "description": form.cleaned_data.get("description") or "",
- }
- resp = api.patch(f"v1/departments/{pk}/", json=payload)
- if not resp.ok:
- messages.error(
- request,
- f"Не удалось обновить отдел ({resp.status}) — {resp.json or resp.text}",
- )
- else:
- messages.success(request, "Отдел обновлён")
- return redirect("employees:department_detail", pk=pk)
-
-
-# ---------- CREATE (staff) ----------
-@login_required
-@require_api_auth
-@require_http_methods(["GET", "POST"])
-def department_create(request):
- """
- Страница создания отдела.
- Работает ТОЛЬКО через API: POST v1/departments/
- Доступ: суперпользователь/страфф или обладатели perms.employees.add_department.
- """
- # Мягкая проверка прав на фронте (API всё равно откажет 403 при отсутствии прав)
- if not (
- request.user.is_superuser
- or request.user.is_staff
- or request.user.has_perm("employees.add_department")
- ):
- return redirect("employees:department_list")
-
- if request.method == "GET":
- form = DepartmentEditForm()
- return render(
- request,
- "employees/department_create.html",
- {
- "form": form,
- "page_title": "Создать отдел",
- "submit_label": "Создать",
- "object": None,
- },
- )
-
- # POST
- form = DepartmentEditForm(request.POST)
- if not form.is_valid():
- return render(
- request,
- "employees/department_create.html",
- {
- "form": form,
- "page_title": "Создать отдел",
- "submit_label": "Создать",
- "object": None,
- },
- status=400,
- )
-
- api = get_api_client(request)
- payload = {
- "name": form.cleaned_data["name"],
- "description": form.cleaned_data.get("description") or "",
- }
-
- # Отправляем в API
- r = api.post("v1/departments/", json=payload)
- if r.ok and isinstance(r.json, dict):
- dept_id = r.json.get("id")
- messages.success(request, "Отдел успешно создан.")
- if dept_id:
- return redirect("employees:department_detail", pk=dept_id)
- # если по какой-то причине id не вернули — уходим в список
- return redirect("employees:department_list")
-
- # Обработка ошибок API: 400 — ошибки сериализатора, 403 — запрет
- try:
- err = r.json()
- except Exception:
- err = None
-
- if getattr(r, "status", None) == 400 and isinstance(err, dict):
- # Пытаемся сопоставить ошибки полям формы
- attached = False
- for field in ("name", "description", "non_field_errors"):
- if field in err:
- msgs = err[field]
- if isinstance(msgs, (list, tuple)) and msgs:
- if field in form.fields:
- form.add_error(field, msgs[0])
- else:
- form.add_error(None, msgs[0])
- attached = True
- if not attached:
- form.add_error(
- None, "Не удалось создать отдел. Проверьте введённые данные."
- )
- status_code = 400
- elif getattr(r, "status", None) == 403:
- form.add_error(None, "Недостаточно прав для создания отдела.")
- status_code = 403
- else:
- form.add_error(
- None, f"Ошибка API ({getattr(r, 'status', None)}). Повторите попытку позже."
- )
- status_code = 502
-
- return render(
- request,
- "employees/department_create.html",
- {
- "form": form,
- "page_title": "Создать отдел",
- "submit_label": "Создать",
- "object": None,
- },
- status=status_code,
- )
-
-
-# ---------- SET HEAD ----------
-@require_api_auth
-@require_http_methods(["POST"])
-def department_set_head(request, pk: int):
- """
- Назначение/снятие руководителя через POST /api/v1/departments/{id}/set_head/.
- Права проверяет API (change_department_head).
- """
- api = get_api_client(request)
- form = SetHeadForm(request.POST)
- if not form.is_valid():
- messages.error(request, "Проверьте форму")
- return redirect("employees:department_detail", pk=pk)
-
- head_id = form.cleaned_data.get("head_id", None)
- payload = {"head_id": head_id if head_id is not None else None} # None → снять
- resp = api.post(f"v1/departments/{pk}/set_head/", json=payload)
- if not resp.ok:
- messages.error(
- request,
- f"Не удалось изменить руководителя ({resp.status}) — {resp.json or resp.text}",
- )
- else:
- messages.success(
- request, "Руководитель обновлён" if head_id else "Руководитель снят"
- )
- return redirect("employees:department_detail", pk=pk)
-
-
-# ---------- SET MEMBER ROLE ----------
-@require_api_auth
-@require_http_methods(["POST"])
-def set_member_role(request, dept_id: int, emp_id: int):
- """
- Серверный proxy: меняем роль сотрудника в отделе.
- Тело: role_id (пусто => снять роль), next (опционально для редиректа).
- """
- api = get_api_client(request)
-
- role_id_raw = request.POST.get("role_id", "")
- role_id = None
- if str(role_id_raw).strip() not in {"", "None", "null"}:
- try:
- role_id = int(role_id_raw)
- except (TypeError, ValueError):
- role_id = None
-
- # заодно не даём менять самому себе (UI тоже скрывает)
- if (str(emp_id) == str(getattr(request.user, "id", None))) and not (
- request.user.is_staff or request.user.is_superuser
- ):
- messages.error(request, "Нельзя изменять собственную роль в отделе.")
- return redirect(
- request.POST.get("next") or reverse("employees:profile", args=("me",))
- )
-
- # пробрасываем в API — он сам проверит, что вы глава отдела
- resp = api.post(
- f"v1/departments/{dept_id}/set_member_role/",
- json={
- "employee_id": emp_id,
- "role_id": role_id,
- },
- )
-
- if resp.ok:
- messages.success(request, "Роль обновлена.")
- else:
- try:
- data = resp.json
- except Exception:
- data = {}
- err = data.get("detail") or data.get("error") or f"HTTP {resp.status}"
- messages.error(request, f"Не удалось обновить роль: {err}")
-
- return redirect(
- request.POST.get("next")
- or reverse("employees:employee_profile", args=(emp_id,))
- )
-
-
-@require_api_auth
-@require_http_methods(["POST"])
-def department_add_member(request, pk: int):
- """
- Прокси-вьюха: добавляет (активирует) сотрудника в отделе через API.
-
- Ожидает:
- POST form-data: employee_id
-
- Поведение:
- - Делает POST в /api/v1/departments/{id}/add_member/ c {"employee_id": }.
- - Сообщения об успехе/ошибке через django.contrib.messages.
- - Редирект обратно на страницу отдела.
- """
- api = get_api_client(request)
-
- raw = (request.POST.get("employee_id") or "").strip()
- try:
- employee_id = int(raw)
- except (TypeError, ValueError):
- messages.error(request, "Не выбран сотрудник.")
- return redirect("employees:department_detail", pk=pk)
-
- resp = api.post(
- f"v1/departments/{pk}/add_member/", json={"employee_id": employee_id}
- )
- if resp.ok:
- messages.success(request, "Сотрудник добавлен в отдел.")
- else:
- # аккуратно вытащим текст ошибки
- detail = ""
- try:
- data = resp.json or {}
- detail = data.get("detail") or data.get("error") or ""
- except Exception:
- detail = resp.text or ""
- messages.error(request, f"Не удалось добавить: {resp.status} {detail}".strip())
-
- return redirect("employees:department_detail", pk=pk)
-
-
-@require_api_auth
-@require_http_methods(["POST"])
-def department_remove_member(request, pk: int):
- """
- Прокси-вьюха: деактивирует (удаляет) сотрудника из отдела через API.
-
- Ожидает:
- POST form-data: employee_id
-
- Поведение:
- - Делает POST в /api/v1/departments/{id}/remove_member/ c {"employee_id": }.
- - Не изменяет роль (эта логика на API и отделена).
- - Сообщения об успехе/ошибке через django.contrib.messages.
- - Редирект обратно на страницу отдела.
- """
- api = get_api_client(request)
-
- raw = (request.POST.get("employee_id") or "").strip()
- try:
- employee_id = int(raw)
- except (TypeError, ValueError):
- messages.error(request, "Не выбран сотрудник.")
- return redirect("employees:department_detail", pk=pk)
-
- resp = api.post(
- f"v1/departments/{pk}/remove_member/", json={"employee_id": employee_id}
- )
- if resp.ok:
- messages.success(request, "Сотрудник удалён из отдела.")
- else:
- try:
- data = resp.json or {}
- detail = data.get("detail") or data.get("error") or ""
- except Exception:
- detail = resp.text or ""
- messages.error(request, f"Не удалось удалить: {resp.status} {detail}".strip())
-
- return redirect("employees:department_detail", pk=pk)
-
-
-@require_api_auth
-@require_POST
-def edit_department_role(request, pk: int):
- """
- Универсальный обработчик ролей отдела:
- - op=create : POST v1/department-roles/ {department: pk, name} + сразу POST set_perms/
- - op=update : PATCH v1/department-roles// {name} + POST set_perms/
- - op=delete : DELETE v1/department-roles//
- """
- api = get_api_client(request)
- op = (request.POST.get("op") or "").lower().strip()
-
- # Быстрая проверка прав (как и в шаблоне)
- dept_resp = api.get(f"v1/departments/{pk}/")
- if not dept_resp.ok:
- messages.error(request, "Отдел не найден.")
- return redirect("employees:department_detail", pk=pk)
- dept = dept_resp.json
- allowed = request.user.is_staff or (
- dept.get("head") and dept["head"].get("id") == request.user.id
- )
- if not allowed:
- messages.error(request, "Недостаточно прав для управления ролями отдела.")
- return redirect("employees:department_detail", pk=pk)
-
- try:
- if op == "create":
- name = (request.POST.get("name") or "").strip()
- if not name:
- messages.error(request, "Название роли не может быть пустым.")
- return redirect("employees:department_detail", pk=pk)
-
- # 1) создаём роль
- resp = api.post(
- "v1/department-roles/", json={"department": pk, "name": name}
- )
- if resp.ok:
- # 2) сразу выставляем права, если они пришли из формы
- role_id = (resp.json or {}).get("id")
- perm_ids = [
- int(x)
- for x in request.POST.getlist("permission_ids")
- if str(x).isdigit()
- ]
- if role_id is not None:
- # ВАЖНО: у set_perms — хвостовой слэш!
- api.post(
- f"v1/department-roles/{role_id}/set_perms/",
- json={"permission_ids": perm_ids},
- )
-
- messages.success(request, f"Роль «{name}» создана.")
- else:
- messages.error(
- request,
- f"Ошибка создания роли: {getattr(resp, 'status', '')} {resp.text or ''}",
- )
-
- elif op == "update":
- role_id = request.POST.get("role_id")
- name = (request.POST.get("name") or "").strip()
-
- # 1) имя
- if name:
- api.patch(f"v1/department-roles/{role_id}/", json={"name": name})
-
- # 2) права (с хвостовым слэшем!)
- perm_ids = [
- int(x)
- for x in request.POST.getlist("permission_ids")
- if str(x).isdigit()
- ]
- resp = api.post(
- f"v1/department-roles/{role_id}/set_perms/",
- json={"permission_ids": perm_ids},
- )
-
- if resp.ok:
- messages.success(request, f"Роль обновлена: «{name}».")
- else:
- messages.error(
- request,
- f"Ошибка обновления роли: {getattr(resp, 'status', '')} {resp.text or ''}",
- )
-
- elif op == "delete":
- role_id = request.POST.get("role_id")
- if not role_id:
- messages.error(request, "Не указан идентификатор роли для удаления.")
- return redirect("employees:department_detail", pk=pk)
-
- resp = api.delete(f"v1/department-roles/{role_id}/")
- if resp.ok or getattr(resp, "status", None) == 204:
- messages.success(request, "Роль удалена.")
- else:
- messages.error(
- request,
- f"Ошибка удаления роли: {getattr(resp, 'status', '')} {resp.text or ''}",
- )
-
- else:
- messages.error(request, "Неизвестная операция.")
- except Exception as exc:
- messages.error(request, f"Ошибка: {exc}")
-
- return redirect("employees:department_detail", pk=pk)
-
-
-# =========================
-# Навыки
-# =========================
-
-
-@login_required
-@require_POST
-@require_api_auth
-def skill_add(request, pk: int):
- api = get_api_client(request)
- skill_id = request.POST.get("skill_id")
- skill_name = (request.POST.get("skill_name") or "").strip()
- payload = {
- k: v for k, v in (("skill_id", skill_id), ("skill_name", skill_name)) if v
- }
- resp = api.post(f"v1/employees/{pk}/add_skill/", data=payload)
- return JsonResponse(resp.json or {"detail": resp.text}, status=resp.status or 500)
-
-
-@login_required
-@require_POST
-@require_api_auth
-def skill_remove(request, pk: int):
- api = get_api_client(request)
- skill_id = request.POST.get("skill_id")
- skill_name = (request.POST.get("skill_name") or "").strip()
- payload = {
- k: v for k, v in (("skill_id", skill_id), ("skill_name", skill_name)) if v
- }
- # NB: у DRF-экшена underscore: remove_skill
- resp = api.post(f"v1/employees/{pk}/remove_skill/", data=payload)
- return JsonResponse(resp.json or {"detail": resp.text}, status=resp.status or 500)
-
-
-# =========================
-# Должности
-# =========================
-
-
-# ---------- CREATE POSITION ----------
-@csrf_protect
-@login_required
-@require_POST
-def position_create_front(request: HttpRequest) -> JsonResponse:
- """
- Создать должность (прокси к POST /api/v1/positions/).
- Ожидает JSON или form-data с полями: name, description?, groups? (list[int]).
- """
- payload = _json_body(request) or request.POST.dict()
- name = (payload.get("name") or "").strip()
- description = (payload.get("description") or "").strip()
-
- # groups может прийти как список, как csv или отсутствовать
- groups = payload.get("groups")
- if isinstance(groups, str):
- groups = [int(x) for x in groups.split(",") if x.strip().isdigit()]
- elif isinstance(groups, list):
- groups = [int(x) for x in groups if str(x).isdigit()]
- else:
- groups = []
-
- if not name:
- return _error("Укажите название должности", 400)
-
- api = get_api_client(request)
- api_payload = {"name": name}
- if description:
- api_payload["description"] = description
- if groups:
- api_payload["groups"] = groups
-
- resp = api.post("v1/positions/", json=api_payload)
- ok, data, status = _api_ok_or_error(resp)
- if not ok:
- return JsonResponse(
- data or {"detail": "Не удалось создать должность"}, status=status
- )
- return _ok(data, status=201)
-
-
-@require_api_auth
-@require_http_methods(["GET"])
-def position_detail_front(request: HttpRequest, pos_id: int) -> JsonResponse:
- """
- Прокси к GET /api/v1/positions/{id}/ — возвращает JSON должности.
- """
- api = get_api_client(request)
- resp = api.get(f"v1/positions/{pos_id}/")
- ok, data, status = _api_ok_or_error(resp)
- if not ok:
- return JsonResponse(
- data or {"detail": "Не удалось получить должность"}, status=status
- )
- return JsonResponse(data or {}, status=200)
-
-
-# ---------- UPDATE POSITION ----------
-@csrf_protect
-@login_required
-@require_http_methods(["PATCH", "POST"])
-def position_update_front(request: HttpRequest, pos_id: int) -> JsonResponse:
- """
- Обновить должность (прокси к PATCH /api/v1/positions/{id}/).
- Приходит JSON с любыми изменяемыми полями (name, description, groups).
- Разрешаем метод PATCH и POST (для совместимости) — на POST тоже шлём PATCH в API.
- """
- payload = _json_body(request) or request.POST.dict()
-
- # Если groups пришли строкой — распарсим
- groups = payload.get("groups", None)
- if isinstance(groups, str):
- payload["groups"] = [int(x) for x in groups.split(",") if x.strip().isdigit()]
-
- api = get_api_client(request)
- resp = api.patch(f"v1/positions/{pos_id}/", json=payload)
- ok, data, status = _api_ok_or_error(resp)
- if not ok:
- return JsonResponse(
- data or {"detail": "Не удалось обновить должность"}, status=status
- )
- return _ok(data)
-
-
-# ---------- ASSIGN POSITION TO EMPLOYEE ----------
-@csrf_protect
-@login_required
-@require_POST
-def employee_set_position_front(request: HttpRequest, emp_id: int) -> JsonResponse:
- """
- Назначить сотруднику position_id (прокси к PATCH /api/v1/employees/{id}/).
- Ожидает JSON/form-data: position_id (int или пусто для снятия).
- """
- try:
- payload = json.loads(request.body.decode("utf-8") or "{}")
- except json.JSONDecodeError:
- return JsonResponse({"detail": "Bad JSON"}, status=400)
-
- raw = payload.get("position_id", None)
-
- try:
- position_id = int(str(raw).strip()) if raw is not None else None
- except (ValueError, TypeError):
- return JsonResponse({"detail": "position_id must be an integer"}, status=400)
-
- if not position_id:
- return JsonResponse({"detail": "position_id is required"}, status=400)
-
- api = get_api_client(request)
- resp = api.patch(f"v1/employees/{emp_id}/", json={"position_id": position_id})
- ok, data, status = _api_ok_or_error(resp)
- if not ok:
- return JsonResponse(
- data or {"detail": "Не удалось назначить должность"}, status=status
- )
- return _ok(data)
-
-
-# Удобная обёртка под «/me/»
-@csrf_protect
-@login_required
-@require_POST
-def employee_set_position_me_front(request: HttpRequest) -> JsonResponse:
- """
- Назначить должность текущему пользователю (прокси к PATCH /api/v1/employees/me/).
- """
- try:
- payload = json.loads(request.body.decode("utf-8") or "{}")
- except json.JSONDecodeError:
- return JsonResponse({"detail": "Bad JSON"}, status=400)
-
- raw = payload.get("position_id", None)
-
- try:
- position_id = int(str(raw).strip()) if raw is not None else None
- except (ValueError, TypeError):
- return JsonResponse({"detail": "position_id must be an integer"}, status=400)
-
- if not position_id:
- return JsonResponse({"detail": "position_id is required"}, status=400)
-
- api = get_api_client(request)
- resp = api.patch("v1/employees/me/", json={"position_id": position_id})
- ok, data, status = _api_ok_or_error(resp)
- if not ok:
- return JsonResponse(
- data or {"detail": "Не удалось назначить должность"}, status=status
- )
- return _ok(data)
diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py
index e29a2680..ab71ebaf 100644
--- a/backend/eusrr_backend/settings.py
+++ b/backend/eusrr_backend/settings.py
@@ -49,7 +49,6 @@ INSTALLED_APPS = [
# Основные приложения
"employees.apps.EmployeesConfig",
"api.apps.ApiConfig",
- "hikcentral.apps.HikcentralConfig",
"schedule", # django-scheduler (проверенная библиотека для календаря)
"calendar_app.apps.CalendarAppConfig", # Наше приложение календаря
"documents.apps.DocumentsConfig",
@@ -60,7 +59,6 @@ INSTALLED_APPS = [
"notifications.apps.NotificationsConfig",
"watson", # django-watson для полнотекстового поиска
"search.apps.SearchConfig",
- "bots",
"finance.apps.FinanceConfig",
"procurement.apps.ProcurementConfig",
"push_notifications", # django-push-notifications для Web Push
@@ -98,7 +96,6 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
- "communications.context_processors.chat_unread_total",
"eusrr_backend.context_processors.branding",
],
},
@@ -115,15 +112,6 @@ USE_SQLITE = os.getenv("USE_SQLITE", "false").lower() == "true"
DATABASES = {
"default": {},
- "hikcentral": {
- "ENGINE": "django.db.backends.postgresql",
- "NAME": "cms",
- "USER": "postgres",
- "PASSWORD": None,
- "HOST": "127.0.0.1",
- "PORT": "5432",
- "OPTIONS": {"options": "-c search_path=platform"},
- },
}
if USE_SQLITE:
diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py
index 0789daf9..858924f3 100644
--- a/backend/eusrr_backend/urls.py
+++ b/backend/eusrr_backend/urls.py
@@ -2,48 +2,17 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
-from django.http import FileResponse
-import os
-def serve_service_worker(request):
- """Обслуживает Service Worker с правильным content-type и заголовками"""
- sw_path = os.path.join(settings.BASE_DIR, 'sw.js')
- try:
- response = FileResponse(
- open(sw_path, 'rb'),
- content_type='application/javascript; charset=utf-8',
- status=200
- )
- # Отключаем кэширование для sw.js (всегда получаем свежую версию)
- response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
- response['Pragma'] = 'no-cache'
- response['Expires'] = '0'
- # Service-Worker-Allowed позволяет контролировать весь домен
- response['Service-Worker-Allowed'] = '/'
- return response
- except Exception as e:
- from django.http import HttpResponse
- return HttpResponse(f"Service Worker not found: {e}", status=500)
+# Импорт Service Worker view из приложения notifications
+from notifications.views import serve_service_worker
urlpatterns = [
path("admin/", admin.site.urls),
- path("auth/", include("employees.urls_front_auth", namespace="auth_front")),
- path("documents/", include("documents.urls", namespace="documents")),
- 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(
- "procurement/",
- include("procurement.urls_frontend", namespace="procurement_frontend")
- ),
path("api/", include(("api.urls", "api"), namespace="api")),
# Service Worker должен быть в корне для правильного scope
path("sw.js", serve_service_worker, name="sw"),
# django-filer URLs для обработки приватных файлов (с проверкой прав доступа)
path("", include("filer.server.urls")),
- path("", include("feed.urls_front", namespace="feed")),
]
# Публичные медиа файлы (без проверки прав) - только в режиме разработки
diff --git a/backend/feed/apps.py b/backend/feed/apps.py
index 4d10c85c..a71f4abf 100644
--- a/backend/feed/apps.py
+++ b/backend/feed/apps.py
@@ -6,6 +6,6 @@ class FeedConfig(AppConfig):
name = 'feed'
def ready(self):
- # Подключаем signals для уведомлений
- import feed.notification_signals # noqa
+ # Подключаем модуль уведомлений (signals регистрируются автоматически)
+ import feed.notifications # noqa
import feed.rules # django-rules: регистрация предикатов и правил доступа
diff --git a/backend/feed/forms.py b/backend/feed/forms.py
deleted file mode 100644
index f48049a6..00000000
--- a/backend/feed/forms.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from django import forms
-from .models import Post, Comment
-
-
-class PostForm(forms.ModelForm):
- class Meta:
- model = Post
- fields = ['type', 'department', 'title', 'body', 'image', 'attachment']
- widgets = {
- 'type': forms.Select(attrs={'class': 'form-select'}),
- 'department': forms.Select(attrs={'class': 'form-select'}),
- 'title': forms.TextInput(attrs={'class': 'form-control'}),
- 'body': forms.Textarea(attrs={'class': 'form-control', 'rows': 5}),
- 'image': forms.ClearableFileInput(attrs={'class': 'form-control'}),
- 'attachment': forms.ClearableFileInput(attrs={'class': 'form-control'}),
- }
-
-
-class CommentForm(forms.ModelForm):
- class Meta:
- model = Comment
- fields = ['text']
- widgets = {
- 'text': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
- }
diff --git a/backend/feed/forms_front.py b/backend/feed/forms_front.py
deleted file mode 100644
index 701a1d7d..00000000
--- a/backend/feed/forms_front.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from django import forms
-
-TYPE_COMPANY = "company"
-TYPE_DEPARTMENT = "department"
-
-
-class LoginForm(forms.Form):
- email = forms.EmailField(label="Email")
- password = forms.CharField(label="Пароль", widget=forms.PasswordInput)
-
-
-class PostCreateForm(forms.Form):
- type = forms.ChoiceField(
- choices=[(TYPE_COMPANY, "Главная (компания)"), (TYPE_DEPARTMENT, "Отдел")]
- )
- department = forms.IntegerField(
- required=False, min_value=1, label="Отдел (для новостей отдела)"
- )
- title = forms.CharField(max_length=200, label="Заголовок")
- body = forms.CharField(widget=forms.Textarea, label="Текст")
- image = forms.ImageField(required=False, label="Изображение")
- attachment = forms.FileField(required=False, label="Вложение")
-
-
-class CommentForm(forms.Form):
- post = forms.IntegerField(widget=forms.HiddenInput)
- text = forms.CharField(
- required=False, widget=forms.Textarea(attrs={"rows": 2}), label="Комментарий"
- )
- image = forms.ImageField(required=False, label="Изображение")
- attachment = forms.FileField(required=False, label="Файл")
-
- def clean(self):
- cleaned = super().clean()
- has_any = bool(
- (cleaned.get("text") or "").strip()
- or cleaned.get("image")
- or cleaned.get("attachment")
- )
- if not has_any:
- raise forms.ValidationError("Нужно указать текст, изображение или файл.")
- return cleaned
diff --git a/backend/feed/notification_signals.py b/backend/feed/notification_signals.py.old
similarity index 100%
rename from backend/feed/notification_signals.py
rename to backend/feed/notification_signals.py.old
diff --git a/backend/feed/notifications/__init__.py b/backend/feed/notifications/__init__.py
new file mode 100644
index 00000000..ca3eca8c
--- /dev/null
+++ b/backend/feed/notifications/__init__.py
@@ -0,0 +1,29 @@
+"""
+Модуль уведомлений для Feed приложения.
+
+Публичный API:
+ - NotificationVerbs - константы глаголов уведомлений
+ - MessageTemplates - шаблоны сообщений
+ - ActionURLs - генераторы URL-адресов
+ - notify_post_reaction - функция для уведомления о реакциях (вызывается из views)
+
+Использование:
+ from feed.notifications import notify_post_reaction
+
+ # В view при добавлении лайка:
+ notify_post_reaction(post, request.user)
+"""
+
+from .config import NotificationVerbs, MessageTemplates, ActionURLs
+from .handlers import notify_post_reaction
+
+# Импортируем handlers и signals для регистрации
+from . import handlers # noqa: F401
+from . import signals # noqa: F401
+
+__all__ = [
+ 'NotificationVerbs',
+ 'MessageTemplates',
+ 'ActionURLs',
+ 'notify_post_reaction',
+]
diff --git a/backend/feed/notifications/config.py b/backend/feed/notifications/config.py
new file mode 100644
index 00000000..d4b897ca
--- /dev/null
+++ b/backend/feed/notifications/config.py
@@ -0,0 +1,93 @@
+"""
+Конфигурация уведомлений для модуля Feed.
+
+Содержит:
+- NotificationVerbs - глаголы для уведомлений
+- MessageTemplates - шаблоны сообщений
+- ActionURLs - URL-адреса действий
+"""
+
+
+class NotificationVerbs:
+ """Глаголы (verbs) для уведомлений feed."""
+
+ NEW_POST = 'feed_new_post'
+ POST_COMMENT = 'feed_post_comment'
+ POST_REACTION = 'feed_post_reaction'
+
+
+class MessageTemplates:
+ """Шаблоны сообщений для уведомлений feed."""
+
+ @staticmethod
+ def new_post_company() -> str:
+ """Шаблон для новости компании."""
+ return 'Новая публикация компании'
+
+ @staticmethod
+ def new_post_department(department_name: str) -> str:
+ """Шаблон для новости отдела."""
+ return f'Новая публикация отдела {department_name}'
+
+ @staticmethod
+ def new_post_employee(author_name: str) -> str:
+ """Шаблон для личной публикации."""
+ return f'Новая публикация от {author_name}'
+
+ @staticmethod
+ def comment(author_name: str, comment_text: str) -> str:
+ """Шаблон для комментария к публикации."""
+ return f'{author_name} прокомментировал: {comment_text}'
+
+ @staticmethod
+ def comment_title() -> str:
+ """Заголовок для уведомления о комментарии."""
+ return 'Новый комментарий к вашей публикации'
+
+ @staticmethod
+ def reaction(user_name: str, post_title: str) -> str:
+ """Шаблон для реакции на публикацию."""
+ return f'{user_name} понравилась публикация "{post_title}"'
+
+ @staticmethod
+ def reaction_title() -> str:
+ """Заголовок для уведомления о реакции."""
+ return 'Ваша публикация понравилась'
+
+
+class ActionURLs:
+ """URL-адреса для действий с публикациями."""
+
+ # Пока все действия ведут на главную страницу
+ # TODO: реализовать прямые ссылки на публикации
+ FEED_HOME = '/'
+
+ @staticmethod
+ def post_detail(post_id: int) -> str:
+ """
+ Возвращает URL-адрес публикации.
+
+ TODO: реализовать отдельную страницу публикации
+ """
+ return f'/?post={post_id}' # Временное решение
+
+
+def truncate_text(text: str, max_length: int = 100) -> str:
+ """
+ Обрезает текст до указанной длины.
+
+ Args:
+ text: Исходный текст
+ max_length: Максимальная длина (по умолчанию 100)
+
+ Returns:
+ Обрезанный текст с многоточием если нужно
+ """
+ if not text:
+ return ''
+
+ text = text.strip()
+ if len(text) <= max_length:
+ return text
+
+ return text[:max_length - 3] + '...'
diff --git a/backend/feed/notifications/handlers.py b/backend/feed/notifications/handlers.py
new file mode 100644
index 00000000..1d045929
--- /dev/null
+++ b/backend/feed/notifications/handlers.py
@@ -0,0 +1,162 @@
+"""
+Бизнес-логика отправки уведомлений для модуля Feed.
+
+Функции:
+- notify_new_post - уведомление о новой публикации
+- notify_comment - уведомление о комментарии
+- notify_post_reaction - уведомление о реакции (лайке)
+- get_post_recipients - определение получателей уведомлений
+"""
+
+from django.contrib.auth import get_user_model
+from notifications.signals import notify
+
+from ..constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE
+from .config import (
+ NotificationVerbs,
+ MessageTemplates,
+ ActionURLs,
+ truncate_text,
+)
+
+Employee = get_user_model()
+
+
+def notify_new_post(post):
+ """
+ Отправляет уведомления о новой публикации.
+
+ Args:
+ post: Экземпляр модели Post
+ """
+ recipients = get_post_recipients(post)
+
+ # Исключаем автора
+ recipients = [r for r in recipients if r.id != post.author.id]
+
+ # Определяем заголовок в зависимости от типа публикации
+ if post.type == TYPE_COMPANY:
+ title = MessageTemplates.new_post_company()
+ elif post.type == TYPE_DEPARTMENT and post.department:
+ title = MessageTemplates.new_post_department(post.department.name)
+ else:
+ author_name = post.author.get_full_name() or post.author.username
+ title = MessageTemplates.new_post_employee(author_name)
+
+ # Отправляем уведомление каждому получателю
+ for recipient in recipients:
+ notify.send(
+ sender=post.author,
+ recipient=recipient,
+ verb=NotificationVerbs.NEW_POST,
+ action_object=post,
+ description=post.title,
+ action_url=ActionURLs.FEED_HOME,
+ data={
+ 'title': title,
+ '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 notify_comment(comment):
+ """
+ Отправляет уведомление автору публикации о новом комментарии.
+
+ Args:
+ comment: Экземпляр модели Comment
+ """
+ post = comment.post
+ comment_author = comment.author
+
+ # Уведомляем автора публикации (если комментарий не от него)
+ if post.author.id == comment_author.id:
+ return
+
+ author_name = comment_author.get_full_name() or comment_author.username
+ comment_text = truncate_text(comment.text, 80)
+
+ notify.send(
+ sender=comment_author,
+ recipient=post.author,
+ verb=NotificationVerbs.POST_COMMENT,
+ action_object=post,
+ description=MessageTemplates.comment(author_name, comment_text),
+ action_url=ActionURLs.FEED_HOME,
+ data={
+ 'title': MessageTemplates.comment_title(),
+ 'post_id': post.id,
+ 'post_title': post.title,
+ 'comment_id': comment.id,
+ 'author_id': comment_author.id,
+ }
+ )
+
+
+def notify_post_reaction(post, user):
+ """
+ Создает уведомление о реакции (лайке) на публикацию.
+
+ Вызывается вручную из view, так как лайки не имеют отдельной модели.
+
+ Args:
+ post: Публикация, которую лайкнули
+ user: Пользователь, который поставил лайк
+ """
+ # Уведомляем автора (если лайк не от него)
+ if post.author.id == user.id:
+ return
+
+ user_name = user.get_full_name() or user.username
+
+ notify.send(
+ sender=user,
+ recipient=post.author,
+ verb=NotificationVerbs.POST_REACTION,
+ action_object=post,
+ description=MessageTemplates.reaction(user_name, post.title),
+ action_url=ActionURLs.FEED_HOME,
+ data={
+ 'title': MessageTemplates.reaction_title(),
+ 'post_id': post.id,
+ 'post_title': post.title,
+ 'user_id': user.id,
+ 'reaction_type': 'like',
+ }
+ )
+
+
+def get_post_recipients(post):
+ """
+ Возвращает список получателей уведомлений для публикации.
+
+ Args:
+ post: Экземпляр модели Post
+
+ Returns:
+ Список пользователей (Employee), которые должны получить уведомление:
+ - 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 []
diff --git a/backend/feed/notifications/signals.py b/backend/feed/notifications/signals.py
new file mode 100644
index 00000000..2c485103
--- /dev/null
+++ b/backend/feed/notifications/signals.py
@@ -0,0 +1,44 @@
+"""
+Django signals для автоматической генерации уведомлений в модуле Feed.
+
+Обрабатывает события:
+- post_save для Post - создание новой публикации
+- post_save для Comment - добавление комментария
+"""
+
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+from ..models import Post, Comment
+from .handlers import notify_new_post, notify_comment
+
+
+@receiver(post_save, sender=Post)
+def create_post_notification(sender, instance, created, **kwargs):
+ """
+ Создает уведомления при создании новой публикации.
+
+ Уведомления отправляются в зависимости от типа:
+ - TYPE_COMPANY - всем сотрудникам
+ - TYPE_DEPARTMENT - сотрудникам отдела
+ - TYPE_EMPLOYEE - подписчикам автора (если реализовано)
+ """
+ if not created:
+ return
+
+ # Отправляем уведомления через универсальную систему
+ # channels.py автоматически обработает через Celery
+ notify_new_post(instance)
+
+
+@receiver(post_save, sender=Comment)
+def create_comment_notification(sender, instance, created, **kwargs):
+ """
+ Создает уведомления при добавлении комментария к публикации.
+
+ Уведомляет автора публикации о новом комментарии.
+ """
+ if not created:
+ return
+
+ notify_comment(instance)
diff --git a/backend/feed/tests.py b/backend/feed/tests.py
deleted file mode 100644
index 7ce503c2..00000000
--- a/backend/feed/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/backend/feed/urls_front.py b/backend/feed/urls_front.py
deleted file mode 100644
index 4d510835..00000000
--- a/backend/feed/urls_front.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from django.urls import path
-
-from . import views_front
-
-app_name = "feed"
-
-urlpatterns = [
- path("", views_front.feed_list, name="feed_list"),
- path("departments//", views_front.department_feed, name="department_feed"),
- path("employees//", views_front.employee_feed, name="employee_feed"),
- path("post//", views_front.post_detail, name="post_detail"),
- path("post//pin/", views_front.pin_post, name="pin_post"),
- path("post//like/", views_front.toggle_like, name="toggle_like"),
-]
diff --git a/backend/feed/views_front.py b/backend/feed/views_front.py
deleted file mode 100644
index 40e1387f..00000000
--- a/backend/feed/views_front.py
+++ /dev/null
@@ -1,252 +0,0 @@
-# backend/feed/views.py
-from __future__ import annotations
-
-from datetime import timedelta
-
-from django.contrib import messages
-from django.contrib.auth.decorators import login_required
-from django.http import HttpResponseRedirect, JsonResponse
-from django.shortcuts import redirect, render
-from django.urls import reverse
-from django.utils import timezone
-
-from api.client import ApiResponse, get_api_client
-from .constants import TYPE_COMPANY, TYPE_DEPARTMENT
-from .forms import CommentForm
-
-# ---------- Константы путей API ----------
-API_POSTS = "v1/posts/"
-API_COMMENTS = "v1/comments/"
-API_DEPARTMENTS = "v1/departments/"
-API_EMPLOYEES = "v1/employees/"
-
-
-# ---------- Вспомогательное: доступ к ApiClient ----------
-def _api(request):
- return get_api_client(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
-
-
-# ---------- Хелперы API для ленты ----------
-def _api_list_posts(request, **params) -> list[dict]:
- resp = _api(request).get(API_POSTS, params=params)
- if not resp.ok:
- messages.error(request, f"Не удалось получить публикации: {resp.status}")
- return []
- return _paged_or_list(resp)
-
-
-def _api_get_post(request, post_id: int) -> dict | None:
- resp = _api(request).get(f"{API_POSTS}{post_id}/")
- if not resp.ok:
- messages.error(request, f"Публикация недоступна: {resp.status}")
- return None
- return resp.json or None
-
-
-def _api_get_comments_for_post(request, post_id: int) -> list[dict]:
- resp = _api(request).get(
- API_COMMENTS, params={"post": post_id, "ordering": "created_at"}
- )
- if not resp.ok:
- messages.error(request, f"Не удалось получить комментарии: {resp.status}")
- return []
- return _paged_or_list(resp)
-
-
-def _api_get_department(request, dept_id: int) -> dict | None:
- resp = _api(request).get(f"{API_DEPARTMENTS}{dept_id}/")
- return resp.json if resp.ok else None
-
-
-def _api_get_employee(request, employee_id: int) -> dict | None:
- resp = _api(request).get(f"{API_EMPLOYEES}{employee_id}/")
- return resp.json if resp.ok else None
-
-
-def _api_list_department_members(request, dept_id: int) -> list[dict]:
- # Предпочтительно: /v1/employees/?department=&is_active=true&ordering=last_name,first_name
- resp = _api(request).get(
- API_EMPLOYEES,
- params={
- "department": dept_id,
- "is_active": "true",
- "ordering": "last_name,first_name",
- },
- )
- return _paged_or_list(resp) if resp.ok else []
-
-
-def _api_list_new_employees(request, limit: int = 10) -> list[dict]:
- """Получить новых сотрудников через API"""
- since = (timezone.now() - timedelta(days=14)).isoformat()
- resp = _api(request).get(
- API_EMPLOYEES,
- params={
- "active": "true",
- "created_at__gte": since,
- "ordering": "-created_at",
- "page_size": limit,
- },
- )
- return _paged_or_list(resp) if resp.ok else []
-
-
-# ---------- Навигационные хелперы ----------
-def _post_back_url(post: dict, user) -> str:
- t = post.get("type")
- if t == TYPE_COMPANY:
- return reverse("feed:feed_list")
- if t == TYPE_DEPARTMENT and post.get("department_id"):
- return reverse("employees:department_detail", args=[post["department_id"]])
- # страница пользователя — все его публикации
- author_id = post.get("author_id") or (
- user.pk if getattr(user, "is_authenticated", False) else None
- )
- return reverse("feed:employee_feed", args=[author_id or 0])
-
-
-# ---------- Ленты ----------
-
-
-def feed_list(request):
- """
- Лента компании - посты и новые сотрудники загружаются через API на клиенте.
- """
- return render(
- request,
- "feed/feed_list.html",
- )
-
-
-@login_required
-def department_feed(request, pk):
- """Лента отдела"""
- department = _api_get_department(request, pk)
- if not department:
- messages.error(request, "Отдел недоступен.")
- return redirect("feed:feed_list")
- posts = _api_list_posts(request, type=TYPE_DEPARTMENT, department=pk)
- emp_links = _api_list_department_members(request, pk)
- return render(
- request,
- "feed/feed_list.html",
- {
- "department": department,
- "posts": posts,
- "new_employees": [],
- "emp_links": emp_links,
- },
- )
-
-
-@login_required
-def employee_feed(request, pk):
- """Лента сотрудника"""
- employee = _api_get_employee(request, pk)
- if not employee:
- messages.error(request, "Сотрудник недоступен.")
- return redirect("feed:feed_list")
- posts = _api_list_posts(request, author=pk)
- return render(
- request, "feed/feed_list.html", {"employee": employee, "posts": posts}
- )
-
-
-# ---------- Детальная + комментарии ----------
-
-
-@login_required
-def post_detail(request, pk):
- """Рендерит детальную страницу поста с комментариями. CRUD через API в JavaScript."""
- post = _api_get_post(request, pk)
- if not post:
- return redirect("feed:feed_list")
-
- comments = _api_get_comments_for_post(request, pk)
- form = CommentForm(initial={"post": pk})
-
- return render(
- request,
- "feed/post_detail.html",
- {"post": post, "comments": comments, "comment_form": form},
- )
-
-
-@login_required
-def pin_post(request, pk):
- post = _api_get_post(request, pk)
- if not post:
- return redirect("feed:feed_list")
- action = "unpin" if post.get("pinned") else "pin"
- resp = _api(request).post(f"{API_POSTS}{pk}/{action}/")
- if resp.ok:
- messages.success(
- request, "Новость закреплена!" if action == "pin" else "Новость откреплена!"
- )
- else:
- messages.error(request, f"Операция недоступна: {resp.status}")
- return redirect(_post_back_url(post, request.user))
-
-
-# ---------- Лайки ----------
-
-
-@login_required
-def toggle_like(request, pk):
- if request.method != "POST":
- return redirect("feed:post_detail", pk=pk)
-
- op = (request.POST.get("op") or "like").lower()
- if op not in {"like", "unlike"}:
- op = "like"
-
- endpoint = "like" if op == "like" else "unlike"
- resp = _api(request).post(f"{API_POSTS}{pk}/{endpoint}/")
-
- if not resp.ok:
- messages.error(request, f"Не удалось {op} публикацию: {resp.status}")
-
- wants_json = (
- "application/json" in request.headers.get("Accept", "")
- or request.headers.get("X-Requested-With") == "XMLHttpRequest"
- )
- if wants_json:
- data = resp.json or {}
- return JsonResponse(
- {
- "ok": bool(resp.ok),
- "post_id": pk,
- "liked": data.get("liked"),
- "likes": data.get("likes_count"),
- }
- )
-
- next_url = (
- request.POST.get("next")
- or request.META.get("HTTP_REFERER")
- or reverse("feed:post_detail", args=[pk])
- )
- return HttpResponseRedirect(next_url)
diff --git a/backend/finance/tests.py b/backend/finance/tests.py
deleted file mode 100644
index 7ce503c2..00000000
--- a/backend/finance/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/backend/finance/urls.py b/backend/finance/urls.py
deleted file mode 100644
index cff07e5d..00000000
--- a/backend/finance/urls.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""URL configuration for finance app."""
-
-from django.urls import path
-
-from . import views
-
-app_name = "finance"
-
-urlpatterns = [
- path("", views.finance_dashboard, name="dashboard"),
-]
diff --git a/backend/finance/views.py b/backend/finance/views.py
deleted file mode 100644
index 9adf5c99..00000000
--- a/backend/finance/views.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""Views for finance app."""
-
-from django.contrib.auth.decorators import login_required
-from django.shortcuts import render
-
-
-@login_required
-def finance_dashboard(request):
- """
- Отображает финансовую панель с встроенным Яндекс.Диском.
- """
- context = {
- "yandex_disk_url": "https://disk.yandex.ru/i/0JWL-v5zyWJi0Q"
- }
- return render(request, "finance/dashboard.html", context)
diff --git a/backend/hikcentral/__init__.py b/backend/hikcentral/__init__.py
deleted file mode 100644
index 5e0902da..00000000
--- a/backend/hikcentral/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-default_app_config = 'hikcentral.apps.HikcentralConfig'
diff --git a/backend/hikcentral/admin.py b/backend/hikcentral/admin.py
deleted file mode 100644
index 8c38f3f3..00000000
--- a/backend/hikcentral/admin.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.contrib import admin
-
-# Register your models here.
diff --git a/backend/hikcentral/apps.py b/backend/hikcentral/apps.py
deleted file mode 100644
index 5f724df6..00000000
--- a/backend/hikcentral/apps.py
+++ /dev/null
@@ -1,10 +0,0 @@
-# hikcentral/apps.py
-
-from django.apps import AppConfig
-
-
-class HikcentralConfig(AppConfig):
- name = 'hikcentral'
-
- def ready(self):
- import hikcentral.signals
diff --git a/backend/hikcentral/migrations/0001_initial.py b/backend/hikcentral/migrations/0001_initial.py
deleted file mode 100644
index 53a47632..00000000
--- a/backend/hikcentral/migrations/0001_initial.py
+++ /dev/null
@@ -1,65 +0,0 @@
-# Generated by Django 5.2.4 on 2025-07-24 09:24
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- initial = True
-
- dependencies = [
- ]
-
- operations = [
- migrations.CreateModel(
- name='HikPerson',
- fields=[
- ('id', models.AutoField(primary_key=True, serialize=False)),
- ('person_group_id', models.IntegerField(blank=True, null=True)),
- ('person_type', models.IntegerField(blank=True, null=True)),
- ('person_code', models.CharField(max_length=16, unique=True)),
- ('given_name', models.CharField(blank=True, max_length=255, null=True)),
- ('family_name', models.CharField(blank=True, max_length=255, null=True)),
- ('full_name', models.CharField(blank=True, max_length=255, null=True)),
- ('gender', models.IntegerField(blank=True, null=True)),
- ('email', models.EmailField(blank=True, max_length=254, null=True)),
- ('phone_number', models.CharField(blank=True, max_length=32, null=True)),
- ('photo_url', models.CharField(blank=True, max_length=512, null=True)),
- ('photo_index', models.IntegerField(blank=True, null=True)),
- ('small_photo_url', models.CharField(blank=True, max_length=512, null=True)),
- ('start_valid_date', models.DateTimeField(blank=True, null=True)),
- ('end_valid_date', models.DateTimeField(blank=True, null=True)),
- ('remark', models.TextField(blank=True, null=True)),
- ('update_time', models.DateTimeField(blank=True, null=True)),
- ('create_time', models.DateTimeField(blank=True, null=True)),
- ('is_deleted', models.IntegerField(default=0)),
- ('object_guid', models.CharField(blank=True, max_length=64, null=True)),
- ('person_from', models.IntegerField(blank=True, null=True)),
- ('rdn', models.CharField(blank=True, max_length=255, null=True)),
- ('dn', models.CharField(blank=True, max_length=255, null=True)),
- ('usn_changed', models.BigIntegerField(blank=True, null=True)),
- ('start_time_differ', models.IntegerField(blank=True, null=True)),
- ('end_time_differ', models.IntegerField(blank=True, null=True)),
- ],
- options={
- 'db_table': 'person',
- 'managed': False,
- },
- ),
- migrations.CreateModel(
- name='HikPersonHeadPic',
- fields=[
- ('id', models.AutoField(primary_key=True, serialize=False)),
- ('standard_head_portrait', models.TextField(blank=True, null=True)),
- ('thumbnail_head_portrait', models.TextField(blank=True, null=True)),
- ('deepmode_head_portrait', models.TextField(blank=True, null=True)),
- ('identi_photo', models.TextField(blank=True, null=True)),
- ('create_time', models.DateTimeField(blank=True, null=True)),
- ('update_time', models.DateTimeField(blank=True, null=True)),
- ],
- options={
- 'db_table': 'person_head_pic',
- 'managed': False,
- },
- ),
- ]
diff --git a/backend/hikcentral/migrations/__init__.py b/backend/hikcentral/migrations/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/hikcentral/models.py b/backend/hikcentral/models.py
deleted file mode 100644
index f870a399..00000000
--- a/backend/hikcentral/models.py
+++ /dev/null
@@ -1,68 +0,0 @@
-# hikcentral/models.py
-
-from django.db import models
-
-
-class HikPerson(models.Model):
- id = models.AutoField(primary_key=True)
- person_group_id = models.IntegerField(blank=True, null=True)
- person_type = models.IntegerField(blank=True, null=True)
- # важно! Уникальный ID от 1 до 16 символов (цифры/буквы)
- person_code = models.CharField(max_length=16, unique=True)
- given_name = models.CharField(max_length=255, blank=True, null=True)
- family_name = models.CharField(max_length=255, blank=True, null=True)
- full_name = models.CharField(max_length=255, blank=True, null=True)
- gender = models.IntegerField(blank=True, null=True)
- email = models.EmailField(blank=True, null=True)
- phone_number = models.CharField(max_length=32, blank=True, null=True)
- photo_url = models.CharField(
- max_length=512, blank=True, null=True, default="")
- photo_index = models.IntegerField(blank=True, null=True)
- small_photo_url = models.CharField(max_length=512, blank=True, null=True)
- start_valid_date = models.DateTimeField(blank=True, null=True)
- end_valid_date = models.DateTimeField(blank=True, null=True)
- remark = models.TextField(blank=True, null=True)
- update_time = models.DateTimeField(blank=True, null=True)
- create_time = models.DateTimeField(blank=True, null=True)
- is_deleted = models.IntegerField(default=0)
- object_guid = models.CharField(max_length=64, blank=True, null=True)
- person_from = models.IntegerField(blank=True, null=True)
- rdn = models.CharField(max_length=255, blank=True, null=True)
- dn = models.CharField(max_length=255, blank=True, null=True)
- usn_changed = models.BigIntegerField(blank=True, null=True)
- start_time_differ = models.IntegerField(blank=True, null=True)
- end_time_differ = models.IntegerField(blank=True, null=True)
-
- class Meta:
- db_table = 'person'
- app_label = 'hikcentral'
- managed = False # очень важно! — Django не управляет этой таблицей
-
- def __str__(self):
- return self.full_name or self.person_code
-
-
-class HikPersonHeadPic(models.Model):
- id = models.AutoField(primary_key=True)
- person = models.ForeignKey(
- HikPerson,
- db_column='person_id',
- on_delete=models.CASCADE,
- related_name='head_pics'
- )
- standard_head_portrait = models.TextField(
- blank=True, null=True) # основное фото (base64-строка)
- thumbnail_head_portrait = models.TextField(
- blank=True, null=True) # миниатюра (base64)
- deepmode_head_portrait = models.TextField(blank=True, null=True)
- identi_photo = models.TextField(blank=True, null=True)
- create_time = models.DateTimeField(blank=True, null=True)
- update_time = models.DateTimeField(blank=True, null=True)
-
- class Meta:
- db_table = 'person_head_pic'
- app_label = 'hikcentral'
- managed = False
-
- def __str__(self):
- return f"Photo for person_id={self.person_id}"
diff --git a/backend/hikcentral/signals.py b/backend/hikcentral/signals.py
deleted file mode 100644
index b9bd3f7e..00000000
--- a/backend/hikcentral/signals.py
+++ /dev/null
@@ -1,77 +0,0 @@
-# # backend/hikcentral/signals.py
-
-# from django.db.models.signals import post_save, post_delete
-# from django.dispatch import receiver
-# from users.models import Employee
-# from .models import HikPerson, HikPersonHeadPic
-# from django.utils import timezone
-# from .utils import gen_person_code, compress_image_to_base64
-
-
-# @receiver(post_save, sender=Employee)
-# def sync_employee_to_hikcentral(sender, instance, created, **kwargs):
-# db = 'hikcentral'
-
-# # Определяем person_code (уникальный)
-# person_code = gen_person_code(instance)
-
-# # Проставим даты
-# now = timezone.now()
-# start_valid = now
-# end_valid = now.replace(year=now.year+10)
-
-# # 1. Создаём/обновляем запись в person
-# person_values = dict(
-# person_group_id=1,
-# person_type=0,
-# person_code=person_code,
-# given_name=instance.first_name,
-# family_name=instance.last_name,
-# full_name=f"{instance.first_name} {instance.last_name}",
-# gender=0, # по аналогии: 0 - не указан, 1 - муж, 2 - жен
-# email=instance.email,
-# phone_number=str(
-# instance.phone_number) if instance.phone_number else "",
-# photo_url=instance.avatar.url if instance.avatar else "",
-# photo_index=0,
-# small_photo_url=instance.avatar.url if instance.avatar else "",
-# start_valid_date=start_valid,
-# end_valid_date=end_valid,
-# remark='',
-# update_time=now,
-# create_time=now,
-# is_deleted=0,
-# person_from=0,
-# usn_changed=0,
-# start_time_differ=10800,
-# end_time_differ=10800,
-# )
-
-# # Сохраняем сотрудника в HikCentral
-# person = HikPerson.objects.using(db).create(**person_values)
-
-# # 2. Картинка (photo/avatar)
-# if instance.avatar:
-# img_b64 = compress_image_to_base64(instance.avatar)
-# HikPersonHeadPic.objects.using(db).create(
-# person_id=person.id,
-# standard_head_portrait=img_b64,
-# thumbnail_head_portrait='',
-# deepmode_head_portrait='',
-# identi_photo='',
-# create_time=now,
-# update_time=now,
-# )
-
-
-# @receiver(post_delete, sender=Employee)
-# def delete_employee_from_hikcentral(sender, instance, **kwargs):
-# db = 'hikcentral'
-# # Найдём всех по имени и почте — или храни person_code в Employee
-# HikPerson.objects.using(db).filter(
-# email=instance.email,
-# full_name=f"{instance.first_name} {instance.last_name}",
-# ).delete()
-# # Не забудь удалить фото
-# # person_ids = list(...)
-# # HikPersonHeadPic.objects.using(db).filter(person_id__in=person_ids).delete()
diff --git a/backend/hikcentral/tests.py b/backend/hikcentral/tests.py
deleted file mode 100644
index 7ce503c2..00000000
--- a/backend/hikcentral/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/backend/hikcentral/utils.py b/backend/hikcentral/utils.py
deleted file mode 100644
index f2186f6b..00000000
--- a/backend/hikcentral/utils.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import base64
-from PIL import Image
-from io import BytesIO
-import random
-import string
-
-
-def gen_person_code(instance):
- import re
- base = str(instance.phone_number or instance.email or instance.pk)
- code = re.sub(r'\W+', '', base)[:16]
-
- # Проверяем уникальность, если не уникален — добавляем символы в конец
- from .models import HikPerson
- existing = (
- HikPerson.objects.using('hikcentral')
- .filter(person_code=code)
- .exists()
- )
- orig_code = code
- while existing:
- # Укорачиваем до 12 символов и дописываем случайные 4 символа
- code = (
- orig_code[:12] + ''.join(random.choices(string.ascii_letters + string.digits, k=4)))[:16]
- existing = (
- HikPerson.objects.using('hikcentral')
- .filter(person_code=code)
- .exists()
- )
- return code
-
-
-def compress_image_to_base64(image_field, target_size_kb=200):
- """Сжимает изображение до размера (примерно) target_size_kb и возвращает base64-строку."""
- if not image_field:
- return ''
- image = Image.open(image_field)
- output = BytesIO()
- quality = 90
- # Сжимать до ~200КБ
- while True:
- output.seek(0)
- image.save(output, format='JPEG', quality=quality)
- size_kb = output.tell() / 1024
- if size_kb <= target_size_kb or quality <= 20:
- break
- quality -= 10
- output.seek(0)
- img_b64 = base64.b64encode(output.read()).decode('utf-8')
- return img_b64
diff --git a/backend/hikcentral/views.py b/backend/hikcentral/views.py
deleted file mode 100644
index 91ea44a2..00000000
--- a/backend/hikcentral/views.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.shortcuts import render
-
-# Create your views here.
diff --git a/backend/notifications/README.md b/backend/notifications/README.md
index 6b06867e..a6f1d15a 100644
--- a/backend/notifications/README.md
+++ b/backend/notifications/README.md
@@ -339,15 +339,46 @@ MIT License - используйте свободно в своих проект
## 🔧 Настройка конфигурации
-Переопределите настройки в `notifications/config.py` или создайте свой конфиг:
+Переопределите настройки в `settings.py` вашего проекта:
```python
-from notifications import config
+# settings.py
-# Переопределение
-config.site_name = lambda: 'Мой Сайт'
-config.site_url = lambda: 'https://mysite.com'
-config.from_email = lambda: 'notifications@mysite.com'
+NOTIFICATIONS_CONFIG = {
+ # Общие
+ 'SITE_NAME': 'Моя Компания',
+ 'NOTIFICATION_MAX_AGE_DAYS': 90,
+
+ # Email
+ 'EMAIL_RATE_LIMIT': '10/m',
+ 'EMAIL_MAX_RETRIES': 3,
+
+ # Web Push
+ 'PUSH_RATE_LIMIT': '50/m',
+ 'PUSH_DEFAULT_ICON': '/static/img/logo-192.png', # None = browser default
+ 'PUSH_DEFAULT_BADGE': '/static/img/badge.png', # None = без badge
+
+ # WebSocket
+ 'WEBSOCKET_MAX_RETRIES': 1,
+
+ # DND режим
+ 'DND_ENABLED': True,
+}
+```
+
+**Важно:** Приложение полностью самодостаточное и не требует project-specific путей.
+Если не указать `PUSH_DEFAULT_ICON`, браузер будет использовать свою дефолтную иконку.
+
+### Использование в коде
+
+```python
+from notifications.config import get, get_config
+
+# Получить всю конфигурацию
+config = get_config()
+
+# Получить конкретную настройку
+icon_url = get('PUSH_DEFAULT_ICON')
```
## 🚀 Запуск Celery worker
diff --git a/backend/notifications/config.py b/backend/notifications/config.py
index d7e5814d..d5b280fe 100644
--- a/backend/notifications/config.py
+++ b/backend/notifications/config.py
@@ -26,6 +26,8 @@ DEFAULT_CONFIG = {
'PUSH_RATE_LIMIT': '50/m', # 50 push в минуту
'PUSH_MAX_RETRIES': 2,
'PUSH_RETRY_DELAY': 60, # 1 минута в секундах
+ 'PUSH_DEFAULT_ICON': None, # URL иконки по умолчанию (None = browser default)
+ 'PUSH_DEFAULT_BADGE': None, # URL badge по умолчанию (None = без badge)
# === WebSocket настройки ===
'WEBSOCKET_MAX_RETRIES': 1,
diff --git a/backend/notifications/senders/push.py b/backend/notifications/senders/push.py
index b15cfff0..891d83a2 100644
--- a/backend/notifications/senders/push.py
+++ b/backend/notifications/senders/push.py
@@ -4,6 +4,7 @@ Web Push отправитель для browser push notifications
from push_notifications.models import WebPushDevice
from .base import BaseNotificationSender
+from ..config import get
class PushNotificationSender(BaseNotificationSender):
@@ -48,11 +49,14 @@ class PushNotificationSender(BaseNotificationSender):
title = f'{actor_str} {notification.verb}'
body = notification.description or ''
+ # Получаем иконки из конфигурации (None = browser default)
+ default_icon = get('PUSH_DEFAULT_ICON')
+ default_badge = get('PUSH_DEFAULT_BADGE')
+
# django-push-notifications использует другой формат
message = {
'head': title,
'body': body,
- 'icon': '/static/img/logo.png',
'url': notification.action_url or '/',
'tag': f'notification-{notification.id}',
'requireInteraction': False,
@@ -62,6 +66,12 @@ class PushNotificationSender(BaseNotificationSender):
}
}
+ # Добавляем иконки только если они настроены
+ if default_icon:
+ message['icon'] = default_icon
+ if default_badge:
+ message['badge'] = default_badge
+
sent_count = 0
failed_count = 0
diff --git a/backend/sw.js b/backend/notifications/static/notifications/sw.js
similarity index 93%
rename from backend/sw.js
rename to backend/notifications/static/notifications/sw.js
index 572c1843..3cde2a95 100644
--- a/backend/sw.js
+++ b/backend/notifications/static/notifications/sw.js
@@ -9,9 +9,9 @@
// Версия Service Worker (для обновления кэша при изменениях)
const SW_VERSION = '1.0.0';
-// Настройки по умолчанию для уведомлений
+// Настройки по умолчанию для уведомлений (без project-specific путей!)
+// Все пути к ресурсам (icon, badge, image) должны приходить с сервера
const DEFAULT_NOTIFICATION_OPTIONS = {
- icon: '/static/img/logo-192.png',
vibrate: [100, 50, 100],
requireInteraction: false,
renotify: true,
@@ -73,13 +73,17 @@ self.addEventListener('push', (event) => {
...notificationData,
title: payload.title || notificationData.title,
body: payload.body || payload.message || '',
- icon: payload.icon || notificationData.icon,
- badge: payload.badge || notificationData.badge,
tag: payload.tag || `notification-${Date.now()}`,
data: payload.data || {}
};
- // Дополнительные опции
+ // Опциональные ресурсы (только если пришли с сервера)
+ if (payload.icon) {
+ notificationData.icon = payload.icon;
+ }
+ if (payload.badge) {
+ notificationData.badge = payload.badge;
+ }
if (payload.image) {
notificationData.image = payload.image;
}
diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py
index f22b0e79..1d9395a1 100644
--- a/backend/notifications/urls.py
+++ b/backend/notifications/urls.py
@@ -1,8 +1,21 @@
"""
-URL routing для модуля notifications
+URL routing для модуля notifications.
-Для обратной совместимости импортирует из api.urls
+Включает:
+- API endpoints (из notifications.api.urls)
+- Service Worker для Web Push уведомлений
"""
-from notifications.api.urls import urlpatterns, app_name # noqa
+from django.urls import path, include
+from notifications.api.urls import urlpatterns as api_urlpatterns, app_name
+from . import views
+
+# Публичные URLs приложения (Service Worker должен быть в корне)
+urlpatterns = [
+ # Service Worker для Web Push уведомлений
+ path('sw.js', views.serve_service_worker, name='notifications_sw'),
+
+ # API endpoints
+ path('api/', include((api_urlpatterns, app_name))),
+]
__all__ = ['urlpatterns', 'app_name']
diff --git a/backend/notifications/views.py b/backend/notifications/views.py
new file mode 100644
index 00000000..7deed0dd
--- /dev/null
+++ b/backend/notifications/views.py
@@ -0,0 +1,43 @@
+"""
+Views для приложения notifications.
+
+Обслуживание Service Worker и других статических ресурсов.
+"""
+from django.http import FileResponse, HttpResponse
+from pathlib import Path
+
+
+def serve_service_worker(request):
+ """
+ Обслуживает Service Worker для Web Push уведомлений.
+
+ Service Worker должен отдаваться с правильными заголовками:
+ - content-type: application/javascript
+ - Cache-Control: no-cache (всегда свежая версия)
+ - Service-Worker-Allowed: / (контроль всего домена)
+ """
+ sw_path = Path(__file__).parent / 'static' / 'notifications' / 'sw.js'
+
+ try:
+ response = FileResponse(
+ open(sw_path, 'rb'),
+ content_type='application/javascript; charset=utf-8',
+ status=200
+ )
+ # Отключаем кэширование для sw.js (всегда получаем свежую версию)
+ response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
+ response['Pragma'] = 'no-cache'
+ response['Expires'] = '0'
+ # Service-Worker-Allowed позволяет контролировать весь домен
+ response['Service-Worker-Allowed'] = '/'
+ return response
+ except FileNotFoundError as e:
+ return HttpResponse(
+ f"Service Worker not found: {sw_path}",
+ status=500
+ )
+ except Exception as e:
+ return HttpResponse(
+ f"Error serving Service Worker: {e}",
+ status=500
+ )
diff --git a/backend/procurement/apps.py b/backend/procurement/apps.py
index 2be13deb..ce8c8a54 100644
--- a/backend/procurement/apps.py
+++ b/backend/procurement/apps.py
@@ -8,5 +8,6 @@ class ProcurementConfig(AppConfig):
def ready(self):
"""Подключение сигналов при загрузке приложения."""
- import procurement.signals # noqa: F401
- import procurement.rules # django-rules: регистрация предикатов и правил доступа
+ import procurement.notifications.signals # noqa: F401
+ # django-rules: регистрация предикатов и правил доступа
+ import procurement.rules # noqa: F401
diff --git a/backend/procurement/forms.py b/backend/procurement/forms.py
deleted file mode 100644
index be5c887c..00000000
--- a/backend/procurement/forms.py
+++ /dev/null
@@ -1,163 +0,0 @@
-"""
-Django Forms для модуля закупок.
-"""
-from datetime import date
-
-from django import forms
-
-from .models import ProcurementRequest, ProcurementItem, Budget
-
-
-class ProcurementRequestForm(forms.ModelForm):
- """Форма создания/редактирования заявки на закупку."""
-
- class Meta:
- model = ProcurementRequest
- fields = ['title', 'description', 'department', 'urgency']
- widgets = {
- 'title': forms.TextInput(attrs={
- 'class': 'form-control',
- 'placeholder': 'Название заявки',
- }),
- 'description': forms.Textarea(attrs={
- 'class': 'form-control',
- 'rows': 4,
- 'placeholder': 'Описание и обоснование закупки',
- }),
- 'department': forms.Select(attrs={
- 'class': 'form-select',
- }),
- 'urgency': forms.Select(attrs={
- 'class': 'form-select',
- }),
- }
-
- def __init__(self, *args, user=None, **kwargs):
- """Инициализация формы с фильтрацией отделов пользователя."""
- super().__init__(*args, **kwargs)
- if user:
- # Ограничиваем выбор отделов теми, в которых состоит юзер
- user_departments = user.departments.filter(
- employeedepartment__is_active=True
- )
- self.fields['department'].queryset = user_departments
-
- # Если у пользователя один отдел - выбираем его по умолчанию
- if user_departments.count() == 1:
- self.fields['department'].initial = user_departments.first()
-
-
-class ProcurementItemForm(forms.ModelForm):
- """Форма добавления позиции в заявку."""
-
- class Meta:
- model = ProcurementItem
- fields = [
- 'name', 'description', 'quantity', 'unit',
- 'estimated_unit_price', 'supplier_info'
- ]
- widgets = {
- 'name': forms.TextInput(attrs={
- 'class': 'form-control',
- 'placeholder': 'Наименование',
- }),
- 'description': forms.Textarea(attrs={
- 'class': 'form-control',
- 'rows': 2,
- 'placeholder': 'Описание позиции',
- }),
- 'quantity': forms.NumberInput(attrs={
- 'class': 'form-control',
- 'min': 1,
- }),
- 'unit': forms.TextInput(attrs={
- 'class': 'form-control',
- 'placeholder': 'шт.',
- }),
- 'estimated_unit_price': forms.NumberInput(attrs={
- 'class': 'form-control',
- 'min': 0.01,
- 'step': '0.01',
- }),
- 'supplier_info': forms.Textarea(attrs={
- 'class': 'form-control',
- 'rows': 2,
- 'placeholder': 'Ссылки на товар, контакты поставщика',
- }),
- }
-
-
-class BudgetForm(forms.ModelForm):
- """Форма создания/редактирования бюджета отдела."""
-
- QUARTER_CHOICES = [
- (1, '1 квартал'),
- (2, '2 квартал'),
- (3, '3 квартал'),
- (4, '4 квартал'),
- ]
-
- class Meta:
- model = Budget
- fields = ['department', 'year', 'quarter', 'allocated_amount']
- widgets = {
- 'department': forms.Select(attrs={
- 'class': 'form-select',
- }),
- 'year': forms.NumberInput(attrs={
- 'class': 'form-control',
- 'min': 2020,
- 'max': 2100,
- }),
- 'quarter': forms.Select(attrs={
- 'class': 'form-select',
- }),
- 'allocated_amount': forms.NumberInput(attrs={
- 'class': 'form-control',
- 'min': 0,
- 'step': '0.01',
- 'placeholder': '0.00',
- }),
- }
- labels = {
- 'department': 'Отдел',
- 'year': 'Год',
- 'quarter': 'Квартал',
- 'allocated_amount': 'Выделенная сумма (₽)',
- }
-
- def __init__(self, *args, **kwargs):
- """Инициализация формы с текущим годом/кварталом."""
- super().__init__(*args, **kwargs)
-
- # Устанавливаем текущий год и квартал по умолчанию для новых бюджетов
- if not self.instance.pk:
- today = date.today()
- self.fields['year'].initial = today.year
- self.fields['quarter'].initial = (today.month - 1) // 3 + 1
-
- def clean(self):
- """Проверка уникальности бюджета."""
- cleaned_data = super().clean()
- department = cleaned_data.get('department')
- year = cleaned_data.get('year')
- quarter = cleaned_data.get('quarter')
-
- if department and year and quarter:
- # Проверяем, нет ли уже такого бюджета
- existing = Budget.objects.filter(
- department=department,
- year=year,
- quarter=quarter
- )
- # Исключаем текущий объект при редактировании
- if self.instance.pk:
- existing = existing.exclude(pk=self.instance.pk)
-
- if existing.exists():
- raise forms.ValidationError(
- f'Бюджет для отдела "{department}" на {year} год, '
- f'{quarter} квартал уже существует.'
- )
-
- return cleaned_data
diff --git a/backend/procurement/notifications/__init__.py b/backend/procurement/notifications/__init__.py
new file mode 100644
index 00000000..d6c4d135
--- /dev/null
+++ b/backend/procurement/notifications/__init__.py
@@ -0,0 +1,68 @@
+"""
+Модуль уведомлений для приложения Procurement.
+
+Обеспечивает автоматическую отправку уведомлений при:
+- Создании новой заявки на закупку
+- Изменении статуса заявки
+- Создании/изменении согласования
+
+Использует универсальную систему уведомлений (backend/notifications).
+Все уведомления (включая WebSocket) отправляются через notify.send().
+
+Структура:
+- config.py - конфигурация (типы уведомлений, шаблоны, URLs)
+- handlers.py - бизнес-логика отправки уведомлений
+- signals.py - Django сигналы для автоматической генерации
+
+Usage:
+ # Сигналы подключаются автоматически через AppConfig.ready()
+ # Для ручной отправки:
+ from procurement.notifications import notify_new_request
+ notify_new_request(request_obj)
+
+ # WebSocket broadcast происходит автоматически через:
+ # notify.send() → channels.py → Celery → WebSocketNotificationSender
+"""
+
+# Импорты для удобства и обратной совместимости
+from .config import NotificationVerbs, MessageTemplates, ActionURLs
+from .handlers import (
+ notify_new_request,
+ notify_approvers,
+ notify_approver,
+ notify_requestor,
+ notify_request_approved,
+ notify_request_rejected,
+ notify_request_completed,
+ notify_request_in_progress,
+ notify_request_cancelled,
+ notify_stage_approved,
+ notify_stage_rejected,
+)
+
+# Сигналы импортируются для регистрации через AppConfig.ready()
+from . import signals # noqa: F401
+
+
+__all__ = [
+ # Config
+ 'NotificationVerbs',
+ 'MessageTemplates',
+ 'ActionURLs',
+
+ # Handlers
+ 'notify_new_request',
+ 'notify_approvers',
+ 'notify_approver',
+ 'notify_requestor',
+ 'notify_request_approved',
+ 'notify_request_rejected',
+ 'notify_request_completed',
+ 'notify_request_in_progress',
+ 'notify_request_cancelled',
+ 'notify_stage_approved',
+ 'notify_stage_rejected',
+
+ # Signals module (для импорта в AppConfig)
+ 'signals',
+]
diff --git a/backend/procurement/notifications/config.py b/backend/procurement/notifications/config.py
new file mode 100644
index 00000000..26b8792b
--- /dev/null
+++ b/backend/procurement/notifications/config.py
@@ -0,0 +1,211 @@
+"""
+Конфигурация уведомлений модуля Procurement.
+
+Определяет типы уведомлений (verbs), шаблоны сообщений и URLs.
+"""
+
+
+# ===== Типы уведомлений (Verbs) =====
+
+class NotificationVerbs:
+ """Типы уведомлений для заявок на закупку."""
+
+ # Новая заявка
+ NEW_REQUEST = 'procurement_new_request'
+
+ # Согласование
+ PENDING_APPROVAL = 'procurement_pending_approval'
+ STAGE_APPROVED = 'procurement_stage_approved'
+
+ # Статусы
+ APPROVED = 'procurement_approved'
+ REJECTED = 'procurement_rejected'
+ IN_PROGRESS = 'procurement_in_progress'
+ COMPLETED = 'procurement_completed'
+ CANCELLED = 'procurement_cancelled'
+
+
+# ===== Шаблоны сообщений =====
+
+class MessageTemplates:
+ """Шаблоны сообщений для уведомлений."""
+
+ @staticmethod
+ def new_request(title: str, total_cost: float) -> tuple[str, str]:
+ """
+ Шаблон для новой заявки.
+
+ Returns:
+ tuple: (notification_title, description)
+ """
+ notification_title = 'Новая заявка на закупку'
+ description = f'Создана заявка "{title}" на сумму {total_cost}₽'
+ return notification_title, description
+
+ @staticmethod
+ def pending_approval(
+ title: str, total_cost: float
+ ) -> tuple[str, str]:
+ """
+ Шаблон для запроса на согласование.
+
+ Returns:
+ tuple: (notification_title, description)
+ """
+ notification_title = 'Требуется согласование'
+ description = (
+ f'Заявка "{title}" ожидает вашего согласования. '
+ f'Сумма: {total_cost}₽'
+ )
+ return notification_title, description
+
+ @staticmethod
+ def stage_approved(approver_name: str, title: str) -> tuple[str, str]:
+ """
+ Шаблон для одобрения этапа согласования.
+
+ Returns:
+ tuple: (notification_title, description)
+ """
+ notification_title = 'Этап согласования пройден'
+ description = f'{approver_name} одобрил заявку "{title}".'
+ return notification_title, description
+
+ @staticmethod
+ def approved(title: str) -> tuple[str, str]:
+ """
+ Шаблон для полного одобрения заявки.
+
+ Returns:
+ tuple: (notification_title, description)
+ """
+ notification_title = 'Заявка одобрена'
+ description = (
+ f'Ваша заявка "{title}" была одобрена. '
+ f'Можно приступать к закупке.'
+ )
+ return notification_title, description
+
+ @staticmethod
+ def rejected(title: str, comment: str = None) -> tuple[str, str]:
+ """
+ Шаблон для отклонения заявки.
+
+ Returns:
+ tuple: (notification_title, description)
+ """
+ notification_title = 'Заявка отклонена'
+ if comment:
+ description = (
+ f'Ваша заявка "{title}" была отклонена. '
+ f'Причина: {comment}'
+ )
+ else:
+ description = (
+ f'Ваша заявка "{title}" была отклонена. '
+ f'Проверьте комментарии согласующих.'
+ )
+ return notification_title, description
+
+ @staticmethod
+ def rejected_by_approver(
+ approver_name: str, title: str, comment: str
+ ) -> tuple[str, str]:
+ """
+ Шаблон для отклонения конкретным согласующим.
+
+ Returns:
+ tuple: (notification_title, description)
+ """
+ notification_title = 'Заявка отклонена'
+ description = (
+ f'{approver_name} отклонил заявку "{title}". '
+ f'Причина: {comment}'
+ )
+ return notification_title, description
+
+ @staticmethod
+ def in_progress(
+ title: str, executor_name: str
+ ) -> tuple[str, str]:
+ """
+ Шаблон для взятия заявки в работу.
+
+ Returns:
+ tuple: (notification_title, description)
+ """
+ notification_title = 'Заявка взята в работу'
+ description = (
+ f'Заявка "{title}" взята в работу '
+ f'пользователем {executor_name}.'
+ )
+ return notification_title, description
+
+ @staticmethod
+ def in_progress_requestor(
+ title: str, executor_name: str
+ ) -> tuple[str, str]:
+ """
+ Шаблон для создателя заявки о взятии в работу.
+
+ Returns:
+ tuple: (notification_title, description)
+ """
+ notification_title = 'Ваша заявка взята в работу'
+ description = (
+ f'Заявка "{title}" взята в работу '
+ f'пользователем {executor_name}.'
+ )
+ return notification_title, description
+
+ @staticmethod
+ def completed(title: str) -> tuple[str, str]:
+ """
+ Шаблон для завершения заявки.
+
+ Returns:
+ tuple: (notification_title, description)
+ """
+ notification_title = 'Заявка завершена'
+ description = f'Закупка по заявке "{title}" завершена.'
+ return notification_title, description
+
+ @staticmethod
+ def completed_approver(title: str) -> tuple[str, str]:
+ """
+ Шаблон для согласующих о завершении заявки.
+
+ Returns:
+ tuple: (notification_title, description)
+ """
+ notification_title = 'Заявка завершена'
+ description = f'Заявка "{title}" успешно завершена.'
+ return notification_title, description
+
+ @staticmethod
+ def cancelled(title: str, reason: str = 'не указана') -> tuple[str, str]:
+ """
+ Шаблон для отмены заявки.
+
+ Returns:
+ tuple: (notification_title, description)
+ """
+ notification_title = 'Заявка отменена'
+ description = (
+ f'Заявка "{title}" была отменена автором. '
+ f'Причина: {reason}'
+ )
+ return notification_title, description
+
+
+# ===== URL для действий =====
+
+class ActionURLs:
+ """URL для переходов из уведомлений."""
+
+ PROCUREMENT_LIST = '/procurement'
+
+ @staticmethod
+ def request_detail(request_id: int) -> str:
+ """URL детальной страницы заявки."""
+ return f'/procurement/requests/{request_id}'
diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py
new file mode 100644
index 00000000..f87ee791
--- /dev/null
+++ b/backend/procurement/notifications/handlers.py
@@ -0,0 +1,340 @@
+"""
+Обработчики (handlers) для отправки уведомлений о заявках на закупку.
+
+Содержит бизнес-логику формирования и отправки уведомлений
+для различных событий в модуле Procurement.
+"""
+
+import logging
+from notifications.signals import notify
+
+from .config import NotificationVerbs, MessageTemplates, ActionURLs
+
+logger = logging.getLogger(__name__)
+
+
+def notify_new_request(request):
+ """
+ Уведомить о новой заявке на закупку.
+
+ Отправляет уведомление руководителю отдела.
+
+ Args:
+ request: Объект ProcurementRequest
+ """
+ if not request.department.head:
+ logger.warning(
+ f"[Procurement] Нет руководителя для отдела {request.department}"
+ )
+ return
+
+ notification_title, description = MessageTemplates.new_request(
+ request.title, request.total_cost
+ )
+
+ notify.send(
+ sender=None,
+ recipient=request.department.head,
+ verb=NotificationVerbs.NEW_REQUEST,
+ action_object=request,
+ description=description,
+ action_url=ActionURLs.PROCUREMENT_LIST,
+ data={
+ 'title': notification_title,
+ 'request_id': request.id,
+ 'total_cost': float(request.total_cost),
+ },
+ )
+
+ logger.info(
+ f"[Procurement] Отправлено уведомление о новой заявке #{request.id} "
+ f"руководителю {request.department.head.username}"
+ )
+
+
+def notify_approvers(request):
+ """
+ Уведомить всех согласующих о необходимости согласования заявки.
+
+ Args:
+ request: Объект ProcurementRequest
+ """
+ from ..constants import ApprovalStatus
+
+ pending_approvals = request.approvals.filter(
+ status=ApprovalStatus.PENDING
+ )
+
+ if not pending_approvals.exists():
+ logger.warning(
+ f"[Procurement] Нет ожидающих согласований "
+ f"для заявки #{request.id}"
+ )
+ return
+
+ for approval in pending_approvals:
+ notify_approver(approval)
+
+
+def notify_approver(approval):
+ """
+ Уведомить конкретного согласующего о необходимости согласования.
+
+ Args:
+ approval: Объект Approval
+ """
+ notification_title, description = MessageTemplates.pending_approval(
+ approval.request.title, approval.request.total_cost
+ )
+
+ notify.send(
+ sender=None,
+ recipient=approval.approver,
+ verb=NotificationVerbs.PENDING_APPROVAL,
+ action_object=approval.request,
+ description=description,
+ action_url=ActionURLs.PROCUREMENT_LIST,
+ data={
+ 'title': notification_title,
+ 'request_id': approval.request.id,
+ 'approval_id': approval.id,
+ 'total_cost': float(approval.request.total_cost),
+ },
+ )
+
+ logger.info(
+ f"[Procurement] Отправлено уведомление о согласовании "
+ f"заявки #{approval.request.id} для {approval.approver.username}"
+ )
+
+
+def notify_requestor(request, verb, title, message):
+ """
+ Уведомить создателя заявки.
+
+ Args:
+ request: Объект ProcurementRequest
+ verb: Тип уведомления (из NotificationVerbs)
+ title: Заголовок уведомления
+ message: Текст сообщения
+ """
+ notify.send(
+ sender=None,
+ recipient=request.requestor,
+ verb=verb,
+ action_object=request,
+ description=message,
+ action_url=ActionURLs.PROCUREMENT_LIST,
+ data={
+ 'title': title,
+ 'request_id': request.id,
+ },
+ )
+
+ logger.info(
+ f"[Procurement] Отправлено уведомление '{verb}' "
+ f"создателю заявки #{request.id}"
+ )
+
+
+def notify_request_approved(request):
+ """
+ Уведомить о полном одобрении заявки.
+
+ Args:
+ request: Объект ProcurementRequest
+ """
+ notification_title, description = MessageTemplates.approved(request.title)
+ notify_requestor(
+ request,
+ NotificationVerbs.APPROVED,
+ notification_title,
+ description
+ )
+
+
+def notify_request_rejected(request):
+ """
+ Уведомить об отклонении заявки.
+
+ Args:
+ request: Объект ProcurementRequest
+ """
+ notification_title, description = MessageTemplates.rejected(request.title)
+ notify_requestor(
+ request,
+ NotificationVerbs.REJECTED,
+ notification_title,
+ description
+ )
+
+
+def notify_request_completed(request):
+ """
+ Уведомить о завершении заявки.
+
+ Отправляет уведомления:
+ - Создателю заявки
+ - Всем одобрившим согласующим
+
+ Args:
+ request: Объект ProcurementRequest
+ """
+ from ..constants import ApprovalStatus
+
+ # Уведомляем создателя
+ notification_title, description = MessageTemplates.completed(request.title)
+ notify_requestor(
+ request,
+ NotificationVerbs.COMPLETED,
+ notification_title,
+ description
+ )
+
+ # Уведомляем всех одобривших согласующих
+ notification_title, description = MessageTemplates.completed_approver(
+ request.title
+ )
+
+ for approval in request.approvals.filter(status=ApprovalStatus.APPROVED):
+ notify.send(
+ sender=None,
+ recipient=approval.approver,
+ verb=NotificationVerbs.COMPLETED,
+ action_object=request,
+ description=description,
+ action_url=ActionURLs.PROCUREMENT_LIST,
+ data={
+ 'title': notification_title,
+ 'request_id': request.id,
+ },
+ )
+
+
+def notify_request_in_progress(request, executor):
+ """
+ Уведомить о взятии заявки в работу.
+
+ Отправляет уведомления:
+ - Создателю заявки (если он не сам взял в работу)
+ - Всем одобрившим согласующим
+
+ Args:
+ request: Объект ProcurementRequest
+ executor: Пользователь, взявший заявку в работу
+ """
+ from ..constants import ApprovalStatus
+
+ executor_name = executor.get_full_name() if executor else 'Сотрудник'
+
+ # Уведомляем создателя (если он не сам взял в работу)
+ if executor and request.requestor != executor:
+ notification_title, description = (
+ MessageTemplates.in_progress_requestor(
+ request.title, executor_name
+ )
+ )
+
+ notify.send(
+ sender=executor,
+ recipient=request.requestor,
+ verb=NotificationVerbs.IN_PROGRESS,
+ action_object=request,
+ description=description,
+ action_url=ActionURLs.PROCUREMENT_LIST,
+ data={
+ 'title': notification_title,
+ 'request_id': request.id,
+ 'executor_id': executor.id,
+ },
+ )
+
+ # Уведомляем всех одобривших согласующих
+ notification_title, description = MessageTemplates.in_progress(
+ request.title, executor_name
+ )
+
+ for approval in request.approvals.filter(status=ApprovalStatus.APPROVED):
+ notify.send(
+ sender=executor,
+ recipient=approval.approver,
+ verb=NotificationVerbs.IN_PROGRESS,
+ action_object=request,
+ description=description,
+ action_url=ActionURLs.PROCUREMENT_LIST,
+ data={
+ 'title': notification_title,
+ 'request_id': request.id,
+ 'executor_id': executor.id if executor else None,
+ },
+ )
+
+
+def notify_request_cancelled(request):
+ """
+ Уведомить об отмене заявки.
+
+ Отправляет уведомления всем причастным согласующим.
+
+ Args:
+ request: Объект ProcurementRequest
+ """
+ reason = getattr(request, 'cancellation_reason', 'не указана')
+ notification_title, description = MessageTemplates.cancelled(
+ request.title, reason
+ )
+
+ for approval in request.approvals.all():
+ notify.send(
+ sender=None,
+ recipient=approval.approver,
+ verb=NotificationVerbs.CANCELLED,
+ action_object=request,
+ description=description,
+ action_url=ActionURLs.PROCUREMENT_LIST,
+ data={
+ 'title': notification_title,
+ 'request_id': request.id,
+ 'reason': reason,
+ },
+ )
+
+
+def notify_stage_approved(approval):
+ """
+ Уведомить о прохождении этапа согласования.
+
+ Args:
+ approval: Объект Approval
+ """
+ approver_name = approval.approver.get_full_name()
+ notification_title, description = MessageTemplates.stage_approved(
+ approver_name, approval.request.title
+ )
+
+ notify_requestor(
+ approval.request,
+ NotificationVerbs.STAGE_APPROVED,
+ notification_title,
+ description
+ )
+
+
+def notify_stage_rejected(approval):
+ """
+ Уведомить об отклонении на этапе согласования.
+
+ Args:
+ approval: Объект Approval
+ """
+ approver_name = approval.approver.get_full_name()
+ notification_title, description = MessageTemplates.rejected_by_approver(
+ approver_name, approval.request.title, approval.comment
+ )
+
+ notify_requestor(
+ approval.request,
+ NotificationVerbs.REJECTED,
+ notification_title,
+ description
+ )
diff --git a/backend/procurement/notifications/signals.py b/backend/procurement/notifications/signals.py
new file mode 100644
index 00000000..fdc4c176
--- /dev/null
+++ b/backend/procurement/notifications/signals.py
@@ -0,0 +1,163 @@
+"""
+Django сигналы для автоматической генерации уведомлений в модуле Procurement.
+
+Обрабатывает события:
+- Создание новой заявки на закупку
+- Изменение статуса заявки
+- Изменение статуса согласования
+
+Все уведомления отправляются через универсальную систему notifications:
+noti fy.send() → channels.py → Celery → WebSocket/Email/Push
+"""
+
+import logging
+from django.db.models.signals import post_save, pre_save
+from django.dispatch import receiver
+
+from ..constants import ApprovalStatus, ProcurementStatus
+from ..models import Approval, ProcurementRequest
+from .handlers import (
+ notify_new_request,
+ notify_approvers,
+ notify_request_approved,
+ notify_request_rejected,
+ notify_request_completed,
+ notify_request_in_progress,
+ notify_request_cancelled,
+ notify_stage_approved,
+ notify_stage_rejected,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@receiver(post_save, sender=ProcurementRequest)
+def create_request_notifications(sender, instance, created, **kwargs):
+ """
+ Создает уведомления при создании или изменении заявки.
+
+ Обрабатывает:
+ 1. Новая заявка - уведомление руководителю отдела
+ 2. Изменение статуса - уведомления соответствующим получателям
+ """
+ try:
+ request = instance
+
+ if created:
+ # Новая заявка создана
+ # WebSocket broadcast будет автоматически через notify.send()
+ notify_new_request(request)
+ return
+
+ # Проверяем изменение статуса
+ if hasattr(request, '_original_status'):
+ old_status = request._original_status
+ new_status = request.status
+
+ if old_status == new_status:
+ return
+
+ # Обработка изменения статуса
+ # WebSocket broadcast будет автоматически через notify.send()
+ _handle_status_change(request, new_status)
+
+ except Exception as e:
+ logger.exception(f"[SIGNAL ERROR] create_request_notifications: {e}")
+
+
+def _handle_status_change(request, new_status):
+ """
+ Обработать изменение статуса заявки.
+
+ Args:
+ request: Объект ProcurementRequest
+ new_status: Новый статус
+ """
+ if new_status == ProcurementStatus.PENDING:
+ # Заявка отправлена на согласование
+ notify_approvers(request)
+
+ elif new_status == ProcurementStatus.APPROVED:
+ # Заявка одобрена
+ notify_request_approved(request)
+
+ elif new_status == ProcurementStatus.REJECTED:
+ # Заявка отклонена
+ notify_request_rejected(request)
+
+ elif new_status == ProcurementStatus.COMPLETED:
+ # Заявка завершена
+ notify_request_completed(request)
+
+ elif new_status == ProcurementStatus.IN_PROGRESS:
+ # Заявка взята в работу
+ executor = request.executor
+ notify_request_in_progress(request, executor)
+
+ elif new_status == ProcurementStatus.CANCELLED:
+ # Заявка отменена
+ notify_request_cancelled(request)
+
+
+@receiver(post_save, sender=Approval)
+def create_approval_notifications(sender, instance, created, **kwargs):
+ """
+ Создает уведомления при создании или изменении согласования.
+
+ Обрабатывает:
+ 1. Новое согласование - уведомление согласующему
+ 2. Одобрение - уведомление создателю заявки
+ 3. Отклонение - уведомление создателю заявки
+ """
+ try:
+ approval = instance
+
+ if created:
+ # Новое согласование создано
+ from .handlers import notify_approver
+ notify_approver(approval)
+ return
+
+ # Проверяем изменение статуса согласования
+ if hasattr(approval, '_original_approval_status'):
+ old_status = approval._original_approval_status
+ new_status = approval.status
+
+ if old_status == new_status:
+ return
+
+ # Обработка изменения статуса согласования
+ if new_status == ApprovalStatus.APPROVED:
+ notify_stage_approved(approval)
+
+ elif new_status == ApprovalStatus.REJECTED:
+ notify_stage_rejected(approval)
+
+ except Exception as e:
+ logger.exception(f"[SIGNAL ERROR] create_approval_notifications: {e}")
+
+
+@receiver(pre_save, sender=ProcurementRequest)
+def track_request_status_change(sender, instance, **kwargs):
+ """
+ Сохранить оригинальный статус для отслеживания изменений.
+ """
+ if instance.pk:
+ try:
+ original = ProcurementRequest.objects.get(pk=instance.pk)
+ instance._original_status = original.status
+ except ProcurementRequest.DoesNotExist:
+ pass
+
+
+@receiver(pre_save, sender=Approval)
+def track_approval_status_change(sender, instance, **kwargs):
+ """
+ Сохранить оригинальный статус согласования для отслеживания изменений.
+ """
+ if instance.pk:
+ try:
+ original = Approval.objects.get(pk=instance.pk)
+ instance._original_approval_status = original.status
+ except Approval.DoesNotExist:
+ pass
diff --git a/backend/procurement/signals.py b/backend/procurement/signals.py.old
similarity index 100%
rename from backend/procurement/signals.py
rename to backend/procurement/signals.py.old
diff --git a/backend/procurement/tests.py b/backend/procurement/tests.py
deleted file mode 100644
index 7ce503c2..00000000
--- a/backend/procurement/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/backend/procurement/urls_frontend.py b/backend/procurement/urls_frontend.py
deleted file mode 100644
index f78f6049..00000000
--- a/backend/procurement/urls_frontend.py
+++ /dev/null
@@ -1,39 +0,0 @@
-"""
-URL конфигурация для HTML страниц модуля закупок (Frontend).
-"""
-
-from django.urls import path
-
-from . import views_frontend as views
-
-app_name = 'procurement_frontend'
-
-urlpatterns = [
- # Дашборд
- path('', views.dashboard, name='dashboard'),
-
- # Заявки на закупку (orders - чтобы не путать с заявлениями requests)
- path('orders/', views.request_list, name='request_list'),
- path('orders/my/', views.my_requests, name='my_requests'),
- path(
- 'orders/pending/',
- views.pending_approvals,
- name='pending_approvals'
- ),
- path('orders/create/', views.request_create, name='request_create'),
- path('orders//', views.request_detail, name='request_detail'),
- path('orders//edit/', views.request_edit, name='request_edit'),
-
- # Оборудование
- path('equipment/', views.equipment_list, name='equipment_list'),
- path(
- 'equipment//',
- views.equipment_detail,
- name='equipment_detail'
- ),
-
- # Бюджеты
- path('budgets/', views.budget_list, name='budget_list'),
- path('budgets/create/', views.budget_create, name='budget_create'),
- path('budgets//edit/', views.budget_edit, name='budget_edit'),
-]
diff --git a/backend/procurement/views_frontend.py b/backend/procurement/views_frontend.py
deleted file mode 100644
index d12ff69b..00000000
--- a/backend/procurement/views_frontend.py
+++ /dev/null
@@ -1,284 +0,0 @@
-"""
-HTML Views для модуля закупок (Frontend).
-
-Все данные загружаются через API на клиенте.
-Views только рендерят layout и передают минимальный контекст.
-"""
-
-from django.contrib.auth.decorators import login_required
-from django.shortcuts import render
-
-
-@login_required
-def dashboard(request):
- """Главная страница модуля закупок.
-
- Данные загружаются через API:
- - /api/procurement/stats/overview/ — статистика
- - /api/procurement/budgets/my-department/ — бюджет отдела
- - /api/procurement/requests/?ordering=-created_at&page_size=5 — последние заявки
- """
- return render(request, 'procurement/dashboard.html', {})
-
-
-@login_required
-def request_list(request):
- """Список заявок на закупку.
-
- Данные загружаются через API:
- - /api/procurement/requests/ — с фильтрами
- - /api/v1/departments/ — для фильтра по отделам
- """
- scope = request.GET.get('scope', 'mine')
- user = request.user
-
- # Читаем фильтры из GET параметров
- filters = {
- 'status': request.GET.get('status', ''),
- 'urgency': request.GET.get('urgency', ''),
- 'period': request.GET.get('period', ''),
- }
-
- # Определяем доступные представления
- can_view_department = user.departments.exists()
- can_view_all = (
- user.is_staff or user.is_superuser or
- user.has_perm('procurement.view_procurementrequest') or
- user.has_perm('procurement.change_procurementrequest')
- )
-
- return render(request, 'procurement/request_list.html', {
- 'scope': scope,
- 'show_tabs': True,
- 'can_view_department': can_view_department,
- 'can_view_all': can_view_all,
- 'filters': filters,
- })
-@login_required
-def my_requests(request):
- """Мои заявки — редирект на request_list."""
- from django.shortcuts import redirect
- return redirect('procurement_frontend:request_list')
-
-
-@login_required
-def pending_approvals(request):
- """Заявки на согласовании.
-
- Данные загружаются через API:
- - /api/procurement/requests/pending_approvals/
- """
- user = request.user
-
- # Определяем доступные представления
- can_view_department = user.departments.exists()
- can_view_all = (
- user.is_staff or user.is_superuser or
- user.has_perm('procurement.view_procurementrequest') or
- user.has_perm('procurement.change_procurementrequest')
- )
-
- return render(request, 'procurement/request_list.html', {
- 'scope': 'pending',
- 'page_title': 'На согласовании',
- 'show_tabs': True,
- 'can_view_department': can_view_department,
- 'can_view_all': can_view_all,
- })
-
-
-@login_required
-@login_required
-def request_detail(request, pk):
- """Детальная страница заявки.
-
- Загружает данные из БД и передаёт в шаблон.
-
- Права на редактирование (can_edit):
- - Админы/Staff → любая заявка в DRAFT
- - Модельные права (change_procurementrequest) → любая заявка в DRAFT
- - Автор заявки → своя заявка в DRAFT
- - Начальник отдела → заявки своего отдела в DRAFT
-
- Права на согласование (can_approve):
- - Пользователи с активным approval для этой заявки
- """
- from django.shortcuts import get_object_or_404
- from procurement.models import ProcurementRequest
-
- request_obj = get_object_or_404(
- ProcurementRequest.objects.select_related(
- 'department', 'requestor', 'executor'
- ).prefetch_related('items', 'approvals__approver'),
- pk=pk
- )
-
- user = request.user
-
- # Проверяем права на редактирование (синхронизировано с API)
- can_edit = False
- if request_obj.is_editable: # Только DRAFT
- # Админы могут редактировать
- if user.is_superuser or user.is_staff:
- can_edit = True
- # Модельные права
- elif user.has_perm('procurement.change_procurementrequest'):
- can_edit = True
- # Автор заявки
- elif request_obj.requestor == user:
- can_edit = True
- # Начальник отдела
- elif request_obj.department.head == user:
- can_edit = True
-
- # Проверяем права на согласование
- can_approve = (
- request_obj.approvals.filter(
- approver=user,
- status='pending'
- ).exists()
- )
-
- return render(request, 'procurement/request_detail.html', {
- 'request_id': pk,
- 'request_obj': request_obj,
- 'can_edit': can_edit,
- 'can_approve': can_approve,
- })
-
-
-@login_required
-def request_create(request):
- """Страница создания заявки.
-
- Данные загружаются через API:
- - /api/v1/departments/ — список отделов
- Создание через:
- - POST /api/procurement/requests/
- """
- return render(request, 'procurement/request_form.html', {
- 'page_title': 'Новая заявка',
- 'request_id': None,
- })
-
-
-@login_required
-def request_edit(request, pk):
- """Страница редактирования заявки.
-
- Данные загружаются через API:
- - /api/v1/procurement/requests/{pk}/ — данные заявки
- - /api/v1/departments/ — список отделов
- Обновление через:
- - PATCH /api/v1/procurement/requests/{pk}/
- """
- return render(request, 'procurement/request_form.html', {
- 'page_title': 'Редактирование заявки',
- 'request_id': pk,
- })
-
-
-@login_required
-def equipment_list(request):
- """Список оборудования.
-
- Данные загружаются через API:
- - /api/procurement/equipment/ — список с фильтрами
- - /api/procurement/equipment-categories/ — категории для фильтра
- - /api/v1/departments/ — отделы для фильтра
- - /api/procurement/equipment/create-options/ — при создании
- """
- return render(request, 'procurement/equipment_list.html', {})
-
-
-@login_required
-def equipment_detail(request, pk):
- """Детальная страница оборудования.
-
- Загружает данные из БД и передаёт в шаблон.
-
- Права на управление (can_manage):
- - Админы/Staff → любое оборудование
- - Начальник отдела → оборудование своего отдела
- - Ответственный за оборудование → своё оборудование
- """
- from django.shortcuts import get_object_or_404
- from procurement.models import Equipment
-
- equipment = get_object_or_404(
- Equipment.objects.select_related(
- 'department',
- 'department__head',
- 'category',
- 'responsible_person',
- 'procurement_item',
- 'procurement_item__request'
- ).prefetch_related(
- 'maintenance_history',
- 'transfer_logs__from_department',
- 'transfer_logs__to_department',
- 'transfer_logs__from_person',
- 'transfer_logs__to_person'
- ),
- pk=pk
- )
-
- user = request.user
-
- # Проверяем права на управление
- can_manage = False
- if user.is_superuser or user.is_staff:
- can_manage = True
- elif equipment.department and equipment.department.head == user:
- can_manage = True
- elif equipment.responsible_person == user:
- can_manage = True
-
- return render(request, 'procurement/equipment_detail.html', {
- 'equipment': equipment,
- 'can_manage': can_manage,
- })
-
-
-# =============================================================================
-# БЮДЖЕТЫ
-# =============================================================================
-
-@login_required
-def budget_list(request):
- """Список бюджетов.
-
- Данные загружаются через API:
- - /api/procurement/budgets/ — список с фильтрами
- - /api/v1/departments/ — отделы для фильтра
- """
- return render(request, 'procurement/budget_list.html', {})
-
-
-@login_required
-def budget_create(request):
- """Создание бюджета.
-
- Данные загружаются через API:
- - /api/v1/departments/ — список отделов
- Создание через:
- - POST /api/procurement/budgets/
- """
- return render(request, 'procurement/budget_form.html', {
- 'budget_id': None,
- })
-
-
-@login_required
-def budget_edit(request, pk):
- """Редактирование бюджета.
-
- Данные загружаются через API:
- - /api/procurement/budgets/{pk}/ — текущие данные
- - /api/v1/departments/ — список отделов
- Обновление через:
- - PATCH /api/procurement/budgets/{pk}/
- """
- return render(request, 'procurement/budget_form.html', {
- 'budget_id': pk,
- })
diff --git a/backend/requests_app/apps.py b/backend/requests_app/apps.py
index 9efa0562..cfc86788 100644
--- a/backend/requests_app/apps.py
+++ b/backend/requests_app/apps.py
@@ -6,6 +6,6 @@ class RequestsAppConfig(AppConfig):
name = 'requests_app'
def ready(self):
- # Подключаем signals для уведомлений
- import requests_app.notification_signals # noqa
+ # Подключаем модуль уведомлений (signals регистрируются автоматически)
+ import requests_app.notifications.signals # noqa
import requests_app.rules # django-rules: регистрация предикатов и правил доступа
diff --git a/backend/requests_app/notification_signals.py b/backend/requests_app/notification_signals.py
deleted file mode 100644
index 94987b53..00000000
--- a/backend/requests_app/notification_signals.py
+++ /dev/null
@@ -1,516 +0,0 @@
-"""
-Signals для автоматической генерации уведомлений в модуле Requests.
-
-Обрабатывает события:
-- Новое заявление (для ответственных/руководителей)
-- Одобрение заявления
-- Отклонение заявления
-- Комментарий к заявлению
-- Изменение статуса заявления
-"""
-
-import logging
-
-from django.contrib.auth import get_user_model
-from django.db.models.signals import m2m_changed, post_save, pre_save
-from django.dispatch import receiver
-from notifications.signals import notify
-
-from .models import Request, RequestComment
-
-Employee = get_user_model()
-logger = logging.getLogger(__name__)
-
-
-# Флаг для отслеживания новых заявлений, ожидающих уведомления
-_pending_new_requests = set()
-
-
-@receiver(post_save, sender=Request)
-def create_request_notifications(sender, instance, created, **kwargs):
- """
- Создает уведомления при создании или изменении заявления.
-
- Обрабатывает:
- 1. Новое заявление - помечаем для отправки уведомлений после установки recipients
- 2. Изменение статуса - уведомление автору
- """
- try:
- request_obj = instance
-
- # Логирование для отладки
- print(
- f"🔔 [SIGNAL] create_request_notifications: created={created}, status={request_obj.status}, id={request_obj.id}"
- )
-
- if created and request_obj.status != "draft":
- # Помечаем заявление как ожидающее отправки уведомлений
- # Уведомления будут отправлены после установки recipients через m2m_changed
- print(
- f"📌 [SIGNAL] Помечаем заявление #{request_obj.id} для отправки уведомлений после установки recipients"
- )
- _pending_new_requests.add(request_obj.id)
- else:
- print(
- f"⏭️ [SIGNAL] Пропускаем: created={created}, status={request_obj.status}"
- )
-
- if not created:
- # Проверяем изменение статуса через сохраненный атрибут _old_status
- if hasattr(request_obj, "_old_status"):
- old_status = request_obj._old_status
- new_status = request_obj.status
-
- if old_status != new_status:
- # Отправляем уведомления напрямую - channels.py автоматически
- # сделает асинхронную отправку через Celery
- notify_status_change(request_obj, old_status, new_status)
- except Exception as e:
- logger.exception(f"[SIGNAL ERROR] create_request_notifications: {e}")
- print(f"❌ [SIGNAL ERROR] create_request_notifications: {e}")
-
-
-@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(m2m_changed, sender=Request.recipients.through)
-def notify_on_recipients_changed(sender, instance, action, **kwargs):
- """
- Отправляет уведомления о новом заявлении ПОСЛЕ установки recipients.
-
- Срабатывает когда recipients устанавливаются через .set(), .add() и т.д.
- """
- try:
- # Проверяем что это завершение операции установки recipients
- if action == "post_add" and instance.id in _pending_new_requests:
- print(
- f"🎯 [M2M_SIGNAL] Recipients установлены для заявления #{instance.id}, отправляем уведомления"
- )
- _pending_new_requests.discard(instance.id)
-
- # Отправляем уведомления напрямую - channels.py автоматически
- # сделает асинхронную отправку через Celery
- notify_new_request(instance)
- except Exception as e:
- logger.exception(f"[SIGNAL ERROR] notify_on_recipients_changed: {e}")
- print(f"❌ [SIGNAL ERROR] notify_on_recipients_changed: {e}")
-
-
-@receiver(m2m_changed, sender=Request.cc_users.through)
-def notify_on_cc_users_changed(sender, instance, action, **kwargs):
- """
- Дополнительная проверка для cc_users.
-
- Если recipients не были установлены, но установлены cc_users,
- также отправляем уведомления.
- """
- try:
- # Если это завершение добавления cc_users И заявление все еще ожидает уведомлений
- if action == "post_add" and instance.id in _pending_new_requests:
- # Проверяем что recipients пусты (значит уведомления еще не отправлены)
- if instance.recipients.count() == 0:
- print(
- f"🎯 [M2M_SIGNAL] CC users установлены для заявления #{instance.id} (без recipients), отправляем уведомления"
- )
- _pending_new_requests.discard(instance.id)
-
- # Отправляем уведомления напрямую - channels.py автоматически
- # сделает асинхронную отправку через Celery
- notify_new_request(instance)
- except Exception as e:
- logger.exception(f"[SIGNAL ERROR] notify_on_cc_users_changed: {e}")
- print(f"❌ [SIGNAL ERROR] notify_on_cc_users_changed: {e}")
-
-
-@receiver(post_save, sender=RequestComment)
-def create_comment_notification(sender, instance, created, **kwargs):
- """
- Создает уведомления при добавлении комментария к заявлению.
- Уведомляет:
- - Автора заявления
- - Всех получателей
- - Всех в копии
- - Согласующего
- - Сотрудников отделов (если sent_to_all_department)
-
- notify.send() → channels.py автоматически отправит через Celery
- """
- if not created:
- return
-
- author = instance.author
- recipients_set = set()
- request_obj = instance.request
-
- # Автор заявки
- if request_obj.employee.id != author.id:
- recipients_set.add(request_obj.employee)
-
- # Получатели
- recipients_set.update(
- request_obj.recipients.filter(is_active=True).exclude(id=author.id)
- )
-
- # CC
- recipients_set.update(
- request_obj.cc_users.filter(is_active=True).exclude(id=author.id)
- )
-
- # Согласующий
- if request_obj.approver and request_obj.approver.id != author.id:
- recipients_set.add(request_obj.approver)
-
- # Если sent_to_all_department - все сотрудники отделов
- if request_obj.sent_to_all_department:
- dept_employees = (
- Employee.objects.filter(
- departments_links__department__in=request_obj.departments.all(),
- departments_links__is_active=True,
- is_active=True,
- )
- .exclude(id__in=[author.id, request_obj.employee.id])
- .distinct()
- )
-
- recipients_set.update(dept_employees)
-
- # Отправляем уведомления
- author_name = author.get_full_name() or author.username
- request_type = request_obj.get_type_display()
- employee_name = (
- request_obj.employee.get_full_name() or request_obj.employee.username
- )
-
- for recipient in recipients_set:
- # Не отправляем уведомление тому, кто сам написал комментарий
- if recipient.id == author.id:
- continue
-
- notify.send(
- sender=author,
- recipient=recipient,
- verb='request_comment',
- action_object=request_obj,
- description=(
- f"{author_name} прокомментировал заявление "
- f'"{request_type}": {instance.text[:100]}'
- ),
- action_url='/requests',
- data={
- 'title': f'💬 Новый комментарий к заявлению от {employee_name}',
- 'request_id': request_obj.id,
- 'request_type': request_obj.type,
- 'comment_id': instance.id,
- 'author_id': author.id,
- },
- )
-
-
-# ===== Вспомогательные функции =====
-
-
-def notify_new_request(request_obj):
- """
- Отправляет уведомление о новом заявлении:
- - Всем основным получателям (recipients) - адресованное им
- - Всем в копии (cc_users) - с пометкой "в копии"
- - Согласующему (approver)
- - Руководителям отделов
- - Пользователям с правом can_process_requests
-
- При sent_to_all_department=True отправляет всем сотрудникам отделов
- """
- logger.info(
- f"\n{'=' * 80}\n"
- f"[notify_new_request] 📨 НАЧАЛО ОТПРАВКИ УВЕДОМЛЕНИЙ О НОВОМ ЗАЯВЛЕНИИ\n"
- f" Request ID: {request_obj.id}\n"
- f" Тип: {request_obj.get_type_display()}\n"
- f" Сотрудник: {request_obj.employee}\n"
- f" Статус: {request_obj.status}\n"
- f"{'=' * 80}"
- )
-
- print(f"📨 [notify_new_request] Начинаем обработку заявления #{request_obj.id}")
-
- recipients_set = set()
-
- # 1. Основные получатели
- recipients_count = request_obj.recipients.count()
- logger.info(
- f"[notify_new_request] Основные получатели (recipients): {recipients_count}"
- )
- print(f" Recipients в БД: {recipients_count}")
- for recipient in request_obj.recipients.filter(is_active=True):
- recipients_set.add(recipient)
- logger.info(
- f"[notify_new_request] ✅ Основной получатель: {recipient.username} (ID={recipient.id}, email={recipient.email})"
- )
- print(
- f" ✅ Добавлен основной получатель: {recipient.username} (ID: {recipient.id})"
- )
-
- # 2. Копия (CC)
- cc_count = request_obj.cc_users.count()
- logger.info(f"[notify_new_request] CC users: {cc_count}")
- print(f" CC users в БД: {cc_count}")
- for cc_user in request_obj.cc_users.filter(is_active=True):
- recipients_set.add(cc_user)
- logger.info(
- f"[notify_new_request] ✅ CC получатель: {cc_user.username} (ID={cc_user.id}, email={cc_user.email})"
- )
- print(f" ✅ Добавлен CC: {cc_user.username} (ID: {cc_user.id})")
-
- # 3. Если sent_to_all_department - все сотрудники отделов
- if request_obj.sent_to_all_department:
- dept_employees = (
- Employee.objects.filter(
- departments_links__department__in=request_obj.departments.all(),
- departments_links__is_active=True,
- is_active=True,
- )
- .exclude(id=request_obj.employee.id)
- .distinct()
- )
-
- recipients_set.update(dept_employees)
-
- # 4. Согласующий
- if request_obj.approver and request_obj.approver.id != request_obj.employee.id:
- recipients_set.add(request_obj.approver)
-
- # 5. Руководители отделов
- for department in request_obj.departments.all():
- if department.head and department.head.id != request_obj.employee.id:
- recipients_set.add(department.head)
-
- # Также проверяем старое поле department для обратной совместимости
- if request_obj.department and request_obj.department.head:
- if request_obj.department.head.id != request_obj.employee.id:
- recipients_set.add(request_obj.department.head)
-
- # 6. Пользователи с правом обрабатывать заявки в этих отделах
- dept_ids = list(request_obj.departments.values_list("id", flat=True))
- if request_obj.department_id and request_obj.department_id not in dept_ids:
- dept_ids.append(request_obj.department_id)
-
- if dept_ids:
- dept_processors = (
- Employee.objects.filter(
- departments_links__department_id__in=dept_ids,
- departments_links__is_active=True,
- departments_links__role__scoped_permissions__code="can_process_requests",
- is_active=True,
- )
- .exclude(id=request_obj.employee.id)
- .distinct()
- )
-
- recipients_set.update(dept_processors)
-
- # Итоговое логирование
- logger.info(f"[notify_new_request] 📊 ИТОГО получателей: {len(recipients_set)}")
- print(
- f"📊 [notify_new_request] Всего получателей для заявления #{request_obj.id}: {len(recipients_set)}"
- )
- if len(recipients_set) == 0:
- logger.warning(
- f"[notify_new_request] ⚠️ НЕТ ПОЛУЧАТЕЛЕЙ! Уведомления не отправлены для request_id={request_obj.id}"
- )
- print(
- f"⚠️ [notify_new_request] НЕТ ПОЛУЧАТЕЛЕЙ! Уведомления не будут отправлены."
- )
- return
-
- for r in recipients_set:
- logger.info(
- f"[notify_new_request] 👤 {r.username} (ID={r.id}, email={r.email})"
- )
- print(f" 👤 {r.username} (ID: {r.id})")
-
- # Определяем тип уведомления для каждого получателя
- author_name = request_obj.employee.get_full_name() or request_obj.employee.username
- request_type = request_obj.get_type_display()
- comment_preview = (
- request_obj.comment[:150] if request_obj.comment else "Без комментария"
- )
-
- logger.info(
- f"[notify_new_request] Начало отправки {len(recipients_set)} уведомлений..."
- )
-
- for recipient in recipients_set:
- # Определяем роль получателя
- is_primary = request_obj.recipients.filter(id=recipient.id).exists()
- is_cc = request_obj.cc_users.filter(id=recipient.id).exists()
- is_approver = request_obj.approver_id == recipient.id
-
- logger.info(
- f"[notify_new_request] 📤 Отправка для {recipient.username}: "
- f"primary={is_primary}, cc={is_cc}, approver={is_approver}"
- )
- print(
- f" 📤 Отправка уведомления для {recipient.username}: "
- f"primary={is_primary}, cc={is_cc}, approver={is_approver}"
- )
-
- # Формируем заголовок и сообщение с учетом роли
- if is_primary:
- # Основной получатель - требует действия
- title = f"📩 Вам адресовано заявление от {author_name}"
- message = f'Тип: "{request_type}". {comment_preview}'
- notification_type = "request_new" # Используем общий тип
- elif is_cc:
- # Копия - информирование
- title = f"📋 Вы в копии заявления от {author_name}"
- message = f'Тип: "{request_type}". {comment_preview}'
- notification_type = "request_new" # Используем общий тип
- elif is_approver:
- title = f"✅ Новое заявление на согласование от {author_name}"
- message = f'Тип: "{request_type}". {comment_preview}'
- notification_type = "request_new" # Используем общий тип
- else:
- # Руководитель отдела или обработчик
- title = f"📝 Новое заявление в отделе от {author_name}"
- message = f'Тип: "{request_type}". {comment_preview}'
- notification_type = "request_new" # Используем общий тип
-
- logger.info(
- f"[notify_new_request] ➡️ Отправка notify.send для {recipient.username}"
- )
-
- notify.send(
- sender=request_obj.employee,
- recipient=recipient,
- verb=notification_type,
- action_object=request_obj,
- description=message,
- action_url='/requests',
- data={
- 'title': title,
- 'request_id': request_obj.id,
- 'request_type': request_obj.type,
- 'employee_id': request_obj.employee.id,
- 'employee_name': author_name,
- 'is_primary_recipient': is_primary,
- 'is_cc': is_cc,
- 'is_approver': is_approver,
- },
- )
-
-
-def notify_status_change(request_obj, old_status, new_status):
- """
- Уведомляет о изменении статуса:
- - Всех получателей (recipients)
- - Всех в копии (cc_users)
- - Сотрудников отделов (если sent_to_all_department)
-
- ВАЖНО:
- - Автор (employee) не получает уведомление о решении (approve/reject)
- - Approver (тот кто принял решение) не получает уведомление о своем же решении
- """
- recipients_to_notify = set()
-
- # ID пользователей которых НЕ нужно уведомлять при approve/reject
- exclude_ids = set()
- if new_status in ("approved", "rejected"):
- # ВАЖНО: Уведомляем автора заявки о решении - это единственный способ
- # информировать его, так как alert() был убран из UI (коммит 935c12a)
- # Не уведомляем только того кто принял решение (он сам нажал кнопку)
- if request_obj.approver_id:
- exclude_ids.add(request_obj.approver_id)
-
- # 1. Автор - УВЕДОМЛЯЕМ всегда (даже при approved/rejected, это важно!)
- recipients_to_notify.add(request_obj.employee)
-
- # 2. Основные получатели (исключая тех кто в exclude_ids)
- recipients_to_notify.update(
- request_obj.recipients.filter(is_active=True).exclude(id__in=exclude_ids)
- )
-
- # 3. Копия (исключая тех кто в exclude_ids)
- recipients_to_notify.update(
- request_obj.cc_users.filter(is_active=True).exclude(id__in=exclude_ids)
- )
-
- # 4. Если sent_to_all_department - все сотрудники отделов (исключая exclude_ids)
- if request_obj.sent_to_all_department:
- # При approve/reject также исключаем approver
- all_exclude_ids = {request_obj.employee.id}
- all_exclude_ids.update(exclude_ids)
-
- dept_employees = (
- Employee.objects.filter(
- departments_links__department__in=request_obj.departments.all(),
- departments_links__is_active=True,
- is_active=True,
- )
- .exclude(id__in=all_exclude_ids)
- .distinct()
- )
-
- recipients_to_notify.update(dept_employees)
-
- # Формируем уведомления с подробной информацией
- request_type = request_obj.get_type_display()
- employee_name = (
- request_obj.employee.get_full_name() or request_obj.employee.username
- )
- approver_name = (
- request_obj.approver.get_full_name() if request_obj.approver else "Руководитель"
- )
-
- for recipient in recipients_to_notify:
- if new_status == 'approved':
- notification_type = 'request_approved'
- title = f'✅ Заявление одобрено: {request_type}'
- message = (
- f'Заявление от {employee_name} "{request_type}" '
- f'одобрено пользователем {approver_name}'
- )
- elif new_status == 'rejected':
- notification_type = 'request_rejected'
- title = f'❌ Заявление отклонено: {request_type}'
- message = (
- f'Заявление от {employee_name} "{request_type}" '
- f'отклонено пользователем {approver_name}'
- )
- else:
- notification_type = 'request_status_changed'
- title = f'🔄 Статус заявления изменен: {request_type}'
- message = (
- f'Статус заявления от {employee_name} "{request_type}" '
- f'изменен: {old_status} → {new_status}'
- )
-
- notify.send(
- sender=request_obj.approver if request_obj.approver else None,
- recipient=recipient,
- verb=notification_type,
- action_object=request_obj,
- description=message,
- action_url='/requests',
- data={
- 'title': title,
- 'request_id': request_obj.id,
- 'request_type': request_obj.type,
- 'employee_id': request_obj.employee.id,
- 'employee_name': employee_name,
- 'old_status': old_status,
- 'new_status': new_status,
- 'approver_id': (
- request_obj.approver.id if request_obj.approver else None
- ),
- 'approver_name': approver_name,
- },
- )
diff --git a/backend/requests_app/notifications/__init__.py b/backend/requests_app/notifications/__init__.py
new file mode 100644
index 00000000..01b62553
--- /dev/null
+++ b/backend/requests_app/notifications/__init__.py
@@ -0,0 +1,48 @@
+"""
+Модуль уведомлений для приложения Requests.
+
+Обеспечивает автоматическую отправку уведомлений при:
+- Создании нового заявления
+- Изменении статуса заявления
+- Добавлении комментария к заявлению
+
+Использует универсальную систему уведомлений (backend/notifications).
+
+Структура:
+- config.py - конфигурация (типы уведомлений, шаблоны сообщений, URLs)
+- handlers.py - бизнес-логика отправки уведомлений
+- signals.py - Django сигналы для автоматической генерации
+
+Usage:
+ # Сигналы подключаются автоматически через AppConfig.ready()
+ # Для ручной отправки:
+ from requests_app.notifications import notify_new_request
+ notify_new_request(request_obj)
+"""
+
+# Импорты для удобства и обратной совместимости
+from .config import NotificationVerbs, MessageTemplates, ActionURLs
+from .handlers import (
+ notify_new_request,
+ notify_status_change,
+ notify_comment,
+)
+
+# Сигналы импортируются для регистрации через AppConfig.ready()
+from . import signals # noqa: F401
+
+
+__all__ = [
+ # Constants
+ 'NotificationVerbs',
+ 'MessageTemplates',
+ 'ActionURLs',
+
+ # Handlers
+ 'notify_new_request',
+ 'notify_status_change',
+ 'notify_comment',
+
+ # Signals module (для импорта в AppConfig)
+ 'signals',
+]
diff --git a/backend/requests_app/notifications/config.py b/backend/requests_app/notifications/config.py
new file mode 100644
index 00000000..195c81d4
--- /dev/null
+++ b/backend/requests_app/notifications/config.py
@@ -0,0 +1,171 @@
+"""
+Конфигурация уведомлений модуля Requests.
+
+Определяет типы уведомлений (verbs), шаблоны сообщений и URL.
+"""
+
+
+# ===== Типы уведомлений (Verbs) =====
+
+class NotificationVerbs:
+ """Типы уведомлений для заявлений."""
+
+ # Новое заявление (универсальный для всех ролей получателей)
+ REQUEST_NEW = 'request_new'
+
+ # Комментарий к заявлению
+ REQUEST_COMMENT = 'request_comment'
+
+ # Изменение статуса
+ REQUEST_APPROVED = 'request_approved'
+ REQUEST_REJECTED = 'request_rejected'
+ REQUEST_STATUS_CHANGED = 'request_status_changed'
+
+
+# ===== Шаблоны сообщений =====
+
+class MessageTemplates:
+ """Шаблоны сообщений для уведомлений."""
+
+ @staticmethod
+ def new_request_primary(
+ author_name: str, request_type: str, comment_preview: str
+ ) -> tuple[str, str]:
+ """
+ Шаблон для основных получателей заявления.
+
+ Returns:
+ tuple: (title, message)
+ """
+ title = f"📩 Вам адресовано заявление от {author_name}"
+ message = f'Тип: "{request_type}". {comment_preview}'
+ return title, message
+
+ @staticmethod
+ def new_request_cc(
+ author_name: str, request_type: str, comment_preview: str
+ ) -> tuple[str, str]:
+ """
+ Шаблон для получателей в копии.
+
+ Returns:
+ tuple: (title, message)
+ """
+ title = f"📋 Вы в копии заявления от {author_name}"
+ message = f'Тип: "{request_type}". {comment_preview}'
+ return title, message
+
+ @staticmethod
+ def new_request_approver(
+ author_name: str, request_type: str, comment_preview: str
+ ) -> tuple[str, str]:
+ """
+ Шаблон для согласующего.
+
+ Returns:
+ tuple: (title, message)
+ """
+ title = f"✅ Новое заявление на согласование от {author_name}"
+ message = f'Тип: "{request_type}". {comment_preview}'
+ return title, message
+
+ @staticmethod
+ def new_request_department(
+ author_name: str, request_type: str, comment_preview: str
+ ) -> tuple[str, str]:
+ """
+ Шаблон для руководителей отдела и обработчиков.
+
+ Returns:
+ tuple: (title, message)
+ """
+ title = f"📝 Новое заявление в отделе от {author_name}"
+ message = f'Тип: "{request_type}". {comment_preview}'
+ return title, message
+
+ @staticmethod
+ def comment(
+ author_name: str,
+ request_type: str,
+ employee_name: str,
+ comment_text: str,
+ ) -> tuple[str, str]:
+ """
+ Шаблон для комментария.
+
+ Returns:
+ tuple: (title, description)
+ """
+ title = f'💬 Новый комментарий к заявлению от {employee_name}'
+ description = (
+ f'{author_name} прокомментировал заявление '
+ f'"{request_type}": {comment_text[:100]}'
+ )
+ return title, description
+
+ @staticmethod
+ def status_approved(
+ employee_name: str, request_type: str, approver_name: str
+ ) -> tuple[str, str]:
+ """
+ Шаблон для одобрения заявления.
+
+ Returns:
+ tuple: (title, message)
+ """
+ title = f'✅ Заявление одобрено: {request_type}'
+ message = (
+ f'Заявление от {employee_name} "{request_type}" '
+ f'одобрено пользователем {approver_name}'
+ )
+ return title, message
+
+ @staticmethod
+ def status_rejected(
+ employee_name: str, request_type: str, approver_name: str
+ ) -> tuple[str, str]:
+ """
+ Шаблон для отклонения заявления.
+
+ Returns:
+ tuple: (title, message)
+ """
+ title = f'❌ Заявление отклонено: {request_type}'
+ message = (
+ f'Заявление от {employee_name} "{request_type}" '
+ f'отклонено пользователем {approver_name}'
+ )
+ return title, message
+
+ @staticmethod
+ def status_changed(
+ employee_name: str,
+ request_type: str,
+ old_status: str,
+ new_status: str,
+ ) -> tuple[str, str]:
+ """
+ Шаблон для изменения статуса.
+
+ Returns:
+ tuple: (title, message)
+ """
+ title = f'🔄 Статус заявления изменен: {request_type}'
+ message = (
+ f'Статус заявления от {employee_name} "{request_type}" '
+ f'изменен: {old_status} → {new_status}'
+ )
+ return title, message
+
+
+# ===== URL для действий =====
+
+class ActionURLs:
+ """URL для переходов из уведомлений."""
+
+ REQUESTS_LIST = '/requests'
+
+ @staticmethod
+ def request_detail(request_id: int) -> str:
+ """URL детальной страницы заявления."""
+ return f'/requests/{request_id}'
diff --git a/backend/requests_app/notifications/handlers.py b/backend/requests_app/notifications/handlers.py
new file mode 100644
index 00000000..5ea18b79
--- /dev/null
+++ b/backend/requests_app/notifications/handlers.py
@@ -0,0 +1,462 @@
+"""
+Обработчики (handlers) для отправки уведомлений о заявлениях.
+
+Содержит бизнес-логику формирования и отправки уведомлений
+для различных событий в модуле Requests.
+"""
+
+import logging
+from django.contrib.auth import get_user_model
+from notifications.signals import notify
+
+from .config import NotificationVerbs, MessageTemplates, ActionURLs
+
+Employee = get_user_model()
+logger = logging.getLogger(__name__)
+
+
+def notify_new_request(request_obj):
+ """
+ Отправляет уведомление о новом заявлении:
+ - Всем основным получателям (recipients) - адресованное им
+ - Всем в копии (cc_users) - с пометкой "в копии"
+ - Согласующему (approver)
+ - Руководителям отделов
+ - Пользователям с правом can_process_requests
+
+ При sent_to_all_department=True отправляет всем сотрудникам отделов
+
+ Args:
+ request_obj: Объект Request
+ """
+ logger.info(
+ f"\n{'=' * 80}\n"
+ f"[notify_new_request] 📨 НАЧАЛО ОТПРАВКИ УВЕДОМЛЕНИЙ\n"
+ f" Request ID: {request_obj.id}\n"
+ f" Тип: {request_obj.get_type_display()}\n"
+ f" Сотрудник: {request_obj.employee}\n"
+ f" Статус: {request_obj.status}\n"
+ f"{'=' * 80}"
+ )
+
+ print(
+ f"📨 [notify_new_request] Начинаем обработку "
+ f"заявления #{request_obj.id}"
+ )
+
+ recipients_set = set()
+
+ # 1. Основные получатели
+ recipients_count = request_obj.recipients.count()
+ logger.info(
+ f"[notify_new_request] Основные получатели: {recipients_count}"
+ )
+ print(f" Recipients в БД: {recipients_count}")
+ for recipient in request_obj.recipients.filter(is_active=True):
+ recipients_set.add(recipient)
+ logger.info(
+ f"[notify_new_request] ✅ Основной получатель: "
+ f"{recipient.username} (ID={recipient.id})"
+ )
+ print(
+ f" ✅ Добавлен основной получатель: "
+ f"{recipient.username} (ID: {recipient.id})"
+ )
+
+ # 2. Копия (CC)
+ cc_count = request_obj.cc_users.count()
+ logger.info(f"[notify_new_request] CC users: {cc_count}")
+ print(f" CC users в БД: {cc_count}")
+ for cc_user in request_obj.cc_users.filter(is_active=True):
+ recipients_set.add(cc_user)
+ logger.info(
+ f"[notify_new_request] ✅ CC получатель: "
+ f"{cc_user.username} (ID={cc_user.id})"
+ )
+ print(f" ✅ Добавлен CC: {cc_user.username} (ID: {cc_user.id})")
+
+ # 3. Если sent_to_all_department - все сотрудники отделов
+ if request_obj.sent_to_all_department:
+ dept_employees = (
+ Employee.objects.filter(
+ departments_links__department__in=(
+ request_obj.departments.all()
+ ),
+ departments_links__is_active=True,
+ is_active=True,
+ )
+ .exclude(id=request_obj.employee.id)
+ .distinct()
+ )
+ recipients_set.update(dept_employees)
+
+ # 4. Согласующий
+ if (
+ request_obj.approver
+ and request_obj.approver.id != request_obj.employee.id
+ ):
+ recipients_set.add(request_obj.approver)
+
+ # 5. Руководители отделов
+ for department in request_obj.departments.all():
+ if department.head and department.head.id != request_obj.employee.id:
+ recipients_set.add(department.head)
+
+ # Также проверяем старое поле department для обратной совместимости
+ if request_obj.department and request_obj.department.head:
+ if request_obj.department.head.id != request_obj.employee.id:
+ recipients_set.add(request_obj.department.head)
+
+ # 6. Пользователи с правом обрабатывать заявки в этих отделах
+ dept_ids = list(request_obj.departments.values_list("id", flat=True))
+ if request_obj.department_id and request_obj.department_id not in dept_ids:
+ dept_ids.append(request_obj.department_id)
+
+ if dept_ids:
+ dept_processors = (
+ Employee.objects.filter(
+ departments_links__department_id__in=dept_ids,
+ departments_links__is_active=True,
+ departments_links__role__scoped_permissions__code=(
+ "can_process_requests"
+ ),
+ is_active=True,
+ )
+ .exclude(id=request_obj.employee.id)
+ .distinct()
+ )
+ recipients_set.update(dept_processors)
+
+ # Итоговое логирование
+ logger.info(
+ f"[notify_new_request] 📊 ИТОГО получателей: "
+ f"{len(recipients_set)}"
+ )
+ print(
+ f"📊 [notify_new_request] Всего получателей для заявления "
+ f"#{request_obj.id}: {len(recipients_set)}"
+ )
+
+ if len(recipients_set) == 0:
+ logger.warning(
+ f"[notify_new_request] ⚠️ НЕТ ПОЛУЧАТЕЛЕЙ! "
+ f"request_id={request_obj.id}"
+ )
+ print(
+ "⚠️ [notify_new_request] НЕТ ПОЛУЧАТЕЛЕЙ! "
+ "Уведомления не будут отправлены."
+ )
+ return
+
+ for r in recipients_set:
+ logger.info(
+ f"[notify_new_request] 👤 {r.username} (ID={r.id})"
+ )
+ print(f" 👤 {r.username} (ID: {r.id})")
+
+ # Подготовка общих данных
+ author_name = request_obj.employee.get_full_name() or request_obj.employee.username
+ request_type = request_obj.get_type_display()
+ comment_preview = (
+ request_obj.comment[:150] if request_obj.comment else "Без комментария"
+ )
+
+ logger.info(
+ f"[notify_new_request] Начало отправки {len(recipients_set)} уведомлений..."
+ )
+
+ # Отправка уведомлений каждому получателю
+ for recipient in recipients_set:
+ _send_new_request_notification(
+ request_obj=request_obj,
+ recipient=recipient,
+ author_name=author_name,
+ request_type=request_type,
+ comment_preview=comment_preview,
+ )
+
+
+def _send_new_request_notification(request_obj, recipient, author_name, request_type, comment_preview):
+ """
+ Отправляет уведомление о новом заявлении конкретному получателю.
+
+ Определяет роль получателя и формирует соответствующее сообщение.
+
+ Args:
+ request_obj: Объект Request
+ recipient: Получатель (Employee)
+ author_name: Имя автора заявления
+ request_type: Тип заявления (отображаемое название)
+ comment_preview: Превью комментария
+ """
+ # Определяем роль получателя
+ is_primary = request_obj.recipients.filter(id=recipient.id).exists()
+ is_cc = request_obj.cc_users.filter(id=recipient.id).exists()
+ is_approver = request_obj.approver_id == recipient.id
+
+ logger.info(
+ f"[notify_new_request] 📤 Отправка для {recipient.username}: "
+ f"primary={is_primary}, cc={is_cc}, approver={is_approver}"
+ )
+ print(
+ f" 📤 Отправка уведомления для {recipient.username}: "
+ f"primary={is_primary}, cc={is_cc}, approver={is_approver}"
+ )
+
+ # Формируем заголовок и сообщение с учетом роли
+ if is_primary:
+ title, message = MessageTemplates.new_request_primary(
+ author_name, request_type, comment_preview
+ )
+ elif is_cc:
+ title, message = MessageTemplates.new_request_cc(
+ author_name, request_type, comment_preview
+ )
+ elif is_approver:
+ title, message = MessageTemplates.new_request_approver(
+ author_name, request_type, comment_preview
+ )
+ else:
+ title, message = MessageTemplates.new_request_department(
+ author_name, request_type, comment_preview
+ )
+
+ logger.info(
+ f"[notify_new_request] ➡️ Отправка notify.send для {recipient.username}"
+ )
+
+ notify.send(
+ sender=request_obj.employee,
+ recipient=recipient,
+ verb=NotificationVerbs.REQUEST_NEW,
+ action_object=request_obj,
+ description=message,
+ action_url=ActionURLs.REQUESTS_LIST,
+ data={
+ 'title': title,
+ 'request_id': request_obj.id,
+ 'request_type': request_obj.type,
+ 'employee_id': request_obj.employee.id,
+ 'employee_name': author_name,
+ 'is_primary_recipient': is_primary,
+ 'is_cc': is_cc,
+ 'is_approver': is_approver,
+ },
+ )
+
+
+def notify_status_change(request_obj, old_status, new_status):
+ """
+ Уведомляет о изменении статуса:
+ - Всех получателей (recipients)
+ - Всех в копии (cc_users)
+ - Сотрудников отделов (если sent_to_all_department)
+
+ ВАЖНО:
+ - Автор (employee) ВСЕГДА получает уведомление о решении (approve/reject)
+ - Approver (тот кто принял решение) НЕ получает уведомление о своем же решении
+
+ Args:
+ request_obj: Объект Request
+ old_status: Старый статус
+ new_status: Новый статус
+ """
+ recipients_to_notify = set()
+
+ # ID пользователей которых НЕ нужно уведомлять при approve/reject
+ exclude_ids = set()
+ if new_status in ("approved", "rejected"):
+ # ВАЖНО: Уведомляем автора заявки о решении - это единственный способ
+ # информировать его, так как alert() был убран из UI (коммит 935c12a)
+ # Не уведомляем только того кто принял решение (он сам нажал кнопку)
+ if request_obj.approver_id:
+ exclude_ids.add(request_obj.approver_id)
+
+ # 1. Автор - УВЕДОМЛЯЕМ всегда (даже при approved/rejected, это важно!)
+ recipients_to_notify.add(request_obj.employee)
+
+ # 2. Основные получатели (исключая тех кто в exclude_ids)
+ recipients_to_notify.update(
+ request_obj.recipients.filter(is_active=True).exclude(id__in=exclude_ids)
+ )
+
+ # 3. Копия (исключая тех кто в exclude_ids)
+ recipients_to_notify.update(
+ request_obj.cc_users.filter(is_active=True).exclude(id__in=exclude_ids)
+ )
+
+ # 4. Если sent_to_all_department - все сотрудники отделов (исключая exclude_ids)
+ if request_obj.sent_to_all_department:
+ # При approve/reject также исключаем approver
+ all_exclude_ids = {request_obj.employee.id}
+ all_exclude_ids.update(exclude_ids)
+
+ dept_employees = (
+ Employee.objects.filter(
+ departments_links__department__in=request_obj.departments.all(),
+ departments_links__is_active=True,
+ is_active=True,
+ )
+ .exclude(id__in=all_exclude_ids)
+ .distinct()
+ )
+ recipients_to_notify.update(dept_employees)
+
+ # Подготовка общих данных
+ request_type = request_obj.get_type_display()
+ employee_name = (
+ request_obj.employee.get_full_name() or request_obj.employee.username
+ )
+ approver_name = (
+ request_obj.approver.get_full_name() if request_obj.approver else "Руководитель"
+ )
+
+ # Отправка уведомлений каждому получателю
+ for recipient in recipients_to_notify:
+ _send_status_change_notification(
+ request_obj=request_obj,
+ recipient=recipient,
+ new_status=new_status,
+ old_status=old_status,
+ employee_name=employee_name,
+ request_type=request_type,
+ approver_name=approver_name,
+ )
+
+
+def _send_status_change_notification(
+ request_obj, recipient, new_status, old_status, employee_name, request_type, approver_name
+):
+ """
+ Отправляет уведомление об изменении статуса конкретному получателю.
+
+ Args:
+ request_obj: Объект Request
+ recipient: Получатель (Employee)
+ new_status: Новый статус
+ old_status: Старый статус
+ employee_name: Имя автора заявления
+ request_type: Тип заявления
+ approver_name: Имя согласующего
+ """
+ # Определяем тип уведомления и формируем сообщение
+ if new_status == 'approved':
+ verb = NotificationVerbs.REQUEST_APPROVED
+ title, message = MessageTemplates.status_approved(
+ employee_name, request_type, approver_name
+ )
+ elif new_status == 'rejected':
+ verb = NotificationVerbs.REQUEST_REJECTED
+ title, message = MessageTemplates.status_rejected(
+ employee_name, request_type, approver_name
+ )
+ else:
+ verb = NotificationVerbs.REQUEST_STATUS_CHANGED
+ title, message = MessageTemplates.status_changed(
+ employee_name, request_type, old_status, new_status
+ )
+
+ notify.send(
+ sender=request_obj.approver if request_obj.approver else None,
+ recipient=recipient,
+ verb=verb,
+ action_object=request_obj,
+ description=message,
+ action_url=ActionURLs.REQUESTS_LIST,
+ data={
+ 'title': title,
+ 'request_id': request_obj.id,
+ 'request_type': request_obj.type,
+ 'employee_id': request_obj.employee.id,
+ 'employee_name': employee_name,
+ 'old_status': old_status,
+ 'new_status': new_status,
+ 'approver_id': (
+ request_obj.approver.id if request_obj.approver else None
+ ),
+ 'approver_name': approver_name,
+ },
+ )
+
+
+def notify_comment(comment_obj):
+ """
+ Отправляет уведомления о новом комментарии к заявлению.
+
+ Уведомляет:
+ - Автора заявления
+ - Всех получателей
+ - Всех в копии
+ - Согласующего
+ - Сотрудников отделов (если sent_to_all_department)
+
+ Args:
+ comment_obj: Объект RequestComment
+ """
+ author = comment_obj.author
+ recipients_set = set()
+ request_obj = comment_obj.request
+
+ # Автор заявки
+ if request_obj.employee.id != author.id:
+ recipients_set.add(request_obj.employee)
+
+ # Получатели
+ recipients_set.update(
+ request_obj.recipients.filter(is_active=True).exclude(id=author.id)
+ )
+
+ # CC
+ recipients_set.update(
+ request_obj.cc_users.filter(is_active=True).exclude(id=author.id)
+ )
+
+ # Согласующий
+ if request_obj.approver and request_obj.approver.id != author.id:
+ recipients_set.add(request_obj.approver)
+
+ # Если sent_to_all_department - все сотрудники отделов
+ if request_obj.sent_to_all_department:
+ dept_employees = (
+ Employee.objects.filter(
+ departments_links__department__in=request_obj.departments.all(),
+ departments_links__is_active=True,
+ is_active=True,
+ )
+ .exclude(id__in=[author.id, request_obj.employee.id])
+ .distinct()
+ )
+ recipients_set.update(dept_employees)
+
+ # Подготовка данных
+ author_name = author.get_full_name() or author.username
+ request_type = request_obj.get_type_display()
+ employee_name = (
+ request_obj.employee.get_full_name() or request_obj.employee.username
+ )
+
+ # Отправка уведомлений
+ for recipient in recipients_set:
+ # Не отправляем уведомление тому, кто сам написал комментарий
+ if recipient.id == author.id:
+ continue
+
+ title, description = MessageTemplates.comment(
+ author_name, request_type, employee_name, comment_obj.text
+ )
+
+ notify.send(
+ sender=author,
+ recipient=recipient,
+ verb=NotificationVerbs.REQUEST_COMMENT,
+ action_object=request_obj,
+ description=description,
+ action_url=ActionURLs.REQUESTS_LIST,
+ data={
+ 'title': title,
+ 'request_id': request_obj.id,
+ 'request_type': request_obj.type,
+ 'comment_id': comment_obj.id,
+ 'author_id': author.id,
+ },
+ )
diff --git a/backend/requests_app/notifications/signals.py b/backend/requests_app/notifications/signals.py
new file mode 100644
index 00000000..fb477bec
--- /dev/null
+++ b/backend/requests_app/notifications/signals.py
@@ -0,0 +1,159 @@
+"""
+Django сигналы для автоматической генерации уведомлений в модуле Requests.
+
+Обрабатывает события:
+- Новое заявление (для ответственных/руководителей)
+- Одобрение/отклонение заявления
+- Комментарий к заявлению
+- Изменение статуса заявления
+"""
+
+import logging
+from django.db.models.signals import m2m_changed, post_save, pre_save
+from django.dispatch import receiver
+
+from ..models import Request, RequestComment
+from .handlers import notify_new_request, notify_status_change, notify_comment
+
+logger = logging.getLogger(__name__)
+
+
+# Флаг для отслеживания новых заявлений, ожидающих уведомления
+_pending_new_requests = set()
+
+
+@receiver(post_save, sender=Request)
+def create_request_notifications(sender, instance, created, **kwargs):
+ """
+ Создает уведомления при создании или изменении заявления.
+
+ Обрабатывает:
+ 1. Новое заявление - помечаем для отправки уведомлений после установки recipients
+ 2. Изменение статуса - уведомление автору
+ """
+ try:
+ request_obj = instance
+
+ # Логирование для отладки
+ logger.debug(
+ f"[SIGNAL] create_request_notifications: created={created}, "
+ f"status={request_obj.status}, id={request_obj.id}"
+ )
+
+ if created and request_obj.status != "draft":
+ # Помечаем заявление как ожидающее отправки уведомлений
+ # Уведомления будут отправлены после установки recipients через m2m_changed
+ logger.debug(
+ f"[SIGNAL] Помечаем заявление #{request_obj.id} для отправки "
+ f"уведомлений после установки recipients"
+ )
+ _pending_new_requests.add(request_obj.id)
+
+ if not created:
+ # Проверяем изменение статуса через сохраненный атрибут _old_status
+ if hasattr(request_obj, "_old_status"):
+ old_status = request_obj._old_status
+ new_status = request_obj.status
+
+ if old_status != new_status:
+ logger.info(
+ f"[SIGNAL] Статус изменен для заявления #{request_obj.id}: "
+ f"{old_status} → {new_status}"
+ )
+ # Отправляем уведомления напрямую - channels.py автоматически
+ # сделает асинхронную отправку через Celery
+ notify_status_change(request_obj, old_status, new_status)
+
+ except Exception as e:
+ logger.exception(f"[SIGNAL ERROR] create_request_notifications: {e}")
+
+
+@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(m2m_changed, sender=Request.recipients.through)
+def notify_on_recipients_changed(sender, instance, action, **kwargs):
+ """
+ Отправляет уведомления о новом заявлении ПОСЛЕ установки recipients.
+
+ Срабатывает когда recipients устанавливаются через .set(), .add() и т.д.
+ """
+ try:
+ # Проверяем что это завершение операции установки recipients
+ if action == "post_add" and instance.id in _pending_new_requests:
+ logger.info(
+ f"[M2M_SIGNAL] Recipients установлены для заявления #{instance.id}, "
+ f"отправляем уведомления"
+ )
+ _pending_new_requests.discard(instance.id)
+
+ # Отправляем уведомления напрямую - channels.py автоматически
+ # сделает асинхронную отправку через Celery
+ notify_new_request(instance)
+
+ except Exception as e:
+ logger.exception(f"[SIGNAL ERROR] notify_on_recipients_changed: {e}")
+
+
+@receiver(m2m_changed, sender=Request.cc_users.through)
+def notify_on_cc_users_changed(sender, instance, action, **kwargs):
+ """
+ Дополнительная проверка для cc_users.
+
+ Если recipients не были установлены, но установлены cc_users,
+ также отправляем уведомления.
+ """
+ try:
+ # Если это завершение добавления cc_users И заявление все еще ожидает уведомлений
+ if action == "post_add" and instance.id in _pending_new_requests:
+ # Проверяем что recipients пусты (значит уведомления еще не отправлены)
+ if instance.recipients.count() == 0:
+ logger.info(
+ f"[M2M_SIGNAL] CC users установлены для заявления #{instance.id} "
+ f"(без recipients), отправляем уведомления"
+ )
+ _pending_new_requests.discard(instance.id)
+
+ # Отправляем уведомления напрямую - channels.py автоматически
+ # сделает асинхронную отправку через Celery
+ notify_new_request(instance)
+
+ except Exception as e:
+ logger.exception(f"[SIGNAL ERROR] notify_on_cc_users_changed: {e}")
+
+
+@receiver(post_save, sender=RequestComment)
+def create_comment_notification(sender, instance, created, **kwargs):
+ """
+ Создает уведомления при добавлении комментария к заявлению.
+
+ Уведомляет:
+ - Автора заявления
+ - Всех получателей
+ - Всех в копии
+ - Согласующего
+ - Сотрудников отделов (если sent_to_all_department)
+
+ notify.send() → channels.py автоматически отправит через Celery
+ """
+ if not created:
+ return
+
+ try:
+ logger.info(
+ f"[SIGNAL] Новый комментарий #{instance.id} к заявлению #{instance.request.id}"
+ )
+ notify_comment(instance)
+
+ except Exception as e:
+ logger.exception(f"[SIGNAL ERROR] create_comment_notification: {e}")
diff --git a/backend/search/models.py b/backend/search/models.py
deleted file mode 100644
index 71a83623..00000000
--- a/backend/search/models.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.db import models
-
-# Create your models here.
diff --git a/backend/search/tests.py b/backend/search/tests.py
deleted file mode 100644
index 7ce503c2..00000000
--- a/backend/search/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/backend/search/urls.py b/backend/search/urls.py
deleted file mode 100644
index e32871d2..00000000
--- a/backend/search/urls.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from django.urls import path
-
-from .views import search_view
-
-app_name = "search"
-
-urlpatterns = [
- path("", search_view, name="search"),
-]
diff --git a/backend/search/views.py b/backend/search/views.py
deleted file mode 100644
index 7cb31342..00000000
--- a/backend/search/views.py
+++ /dev/null
@@ -1,158 +0,0 @@
-# backend/search/views.py
-"""
-Полнотекстовый поиск через django-watson.
-
-Заменяет предыдущую реализацию с ручными Q-объектами на
-автоматическую индексацию watson.
-"""
-from __future__ import annotations
-
-from typing import Any, Dict, List, Literal, TypedDict
-from collections import Counter
-
-from django.contrib.auth.decorators import login_required
-from django.http import HttpRequest, HttpResponse
-from django.shortcuts import render
-from django.utils.html import escape
-from watson.search import search as watson_search
-
-from feed.models import Post
-from employees.models import Employee, Department
-from requests_app.models import Request
-
-
-class SearchItem(TypedDict):
- """Элемент единой выдачи поиска.
-
- Attributes:
- model_name (Literal["post","employee","department","request","chat","message","event"]):
- Тип найденной сущности.
- object (Any): Экземпляр модели, который будет отрисован в шаблоне.
- """
-
- model_name: Literal[
- "post", "employee", "department", "request", "chat", "message", "event"
- ]
- object: Any
-
-
-# ------------------------ ВСПОМОГАТЕЛЬНЫЕ УТИЛИТЫ ------------------------
-
-
-def _is_hr(user: Employee) -> bool:
- """Проверяет расширенные права на просмотр заявлений.
-
- Args:
- user (Employee): Текущий пользователь.
-
- Returns:
- bool: True, если пользователь может видеть все заявления.
-
- Notes:
- Используются пермишены из requests_app.
- """
- return user.has_perm("requests_app.can_view_all_requests") or user.has_perm(
- "requests_app.can_process_requests"
- )
-
-def _get_model_name(obj: Any) -> str:
- """Определяет тип модели для SearchItem.
-
- Args:
- obj: Объект модели из результатов поиска.
-
- Returns:
- str: Название типа модели (post, employee, department, etc.)
- """
- model = type(obj)
- model_name = model.__name__.lower()
-
- # Маппинг моделей на типы для SearchItem
- mapping = {
- "post": "post",
- "employee": "employee",
- "department": "department",
- "request": "request",
- "chat": "chat",
- "message": "message",
- "calendarevent": "event",
- }
-
- return mapping.get(model_name, model_name)
-
-
-# -------------------------------- ВЬЮХА --------------------------------
-
-
-@login_required
-def search_view(request: HttpRequest) -> HttpResponse:
- """Единый поиск по системе через django-watson.
-
- Args:
- request (HttpRequest): Запрос (`GET["q"]`).
-
- Returns:
- HttpResponse: Рендер `templates/search/results.html` с плоским списком.
-
- Контекст шаблона:
- - query: str — исходная строка запроса
- - results: List[SearchItem] — плоский список элементов
- - counts: Dict[str, int] — счётчики по типам:
- keys: post, employee, department, request, chat, message, event
- - total: int — сумма всех счётчиков
- """
- raw_q = request.GET.get("q", "") or ""
- query = escape(raw_q.strip())
-
- items: List[SearchItem] = []
- counts: Dict[str, int] = {
- "post": 0,
- "employee": 0,
- "department": 0,
- "request": 0,
- "chat": 0,
- "message": 0,
- "event": 0,
- }
-
- if query:
- # Выполняем поиск через watson
- search_results = watson_search(query)
-
- # Группируем по типам моделей
- by_model: Dict[str, List[Any]] = {}
- for result in search_results:
- obj = result.object
- model_name = _get_model_name(obj)
-
- # Фильтрация заявлений по правам доступа
- if model_name == "request":
- is_hr = _is_hr(request.user) # type: ignore[arg-type]
- # Проверяем, что объект - это действительно Request с полем employee
- if not is_hr and hasattr(obj, 'employee') and obj.employee != request.user: # type: ignore[attr-defined]
- continue # Пропускаем чужие заявления
-
- if model_name not in by_model:
- by_model[model_name] = []
- by_model[model_name].append(obj)
-
- # Формируем итоговый список с ограничением 10 элементов на тип
- for model_name in ["post", "employee", "department", "request",
- "chat", "message", "event"]:
- objects = by_model.get(model_name, [])
- counts[model_name] = len(objects)
-
- # Берем первые 10 для отображения
- for obj in objects[:10]:
- items.append({
- "model_name": model_name, # type: ignore[typeddict-item]
- "object": obj
- })
-
- ctx = {
- "query": query,
- "results": items,
- "counts": counts,
- "total": sum(counts.values()),
- }
- return render(request, "search/results.html", ctx)
diff --git a/backend/static/sw.js b/backend/static/sw.js
deleted file mode 100644
index 154c122a..00000000
--- a/backend/static/sw.js
+++ /dev/null
@@ -1,192 +0,0 @@
-/**
- * Service Worker для обработки Web Push уведомлений
- *
- * Этот файл должен находиться в корне static/ для правильного scope.
- * Service Worker перехватывает push-события от браузера и показывает
- * нативные уведомления даже когда страница закрыта.
- */
-
-// Версия Service Worker (для обновления кэша при изменениях)
-const SW_VERSION = '1.0.0';
-
-// Настройки по умолчанию для уведомлений
-const DEFAULT_NOTIFICATION_OPTIONS = {
- icon: '/static/img/logo-192.png',
- badge: '/static/img/badge-72.png',
- vibrate: [100, 50, 100],
- requireInteraction: false,
- renotify: true,
-};
-
-/**
- * Событие установки Service Worker
- */
-self.addEventListener('install', (event) => {
- console.log('[SW] Service Worker installing, version:', SW_VERSION);
- // Немедленно активировать новую версию
- self.skipWaiting();
-});
-
-/**
- * Событие активации Service Worker
- */
-self.addEventListener('activate', (event) => {
- console.log('[SW] Service Worker activated, version:', SW_VERSION);
- // Захватить все клиенты без перезагрузки
- event.waitUntil(self.clients.claim());
-});
-
-/**
- * Обработка входящих push-уведомлений
- *
- * Формат данных с сервера:
- * {
- * "title": "Заголовок уведомления",
- * "body": "Текст уведомления",
- * "icon": "/static/img/...", // опционально
- * "badge": "/static/img/...", // опционально
- * "tag": "notification-id", // для группировки
- * "data": {
- * "url": "/path/to/page", // куда перейти при клике
- * "notification_id": 123,
- * "category": "requests",
- * ...
- * }
- * }
- */
-self.addEventListener('push', (event) => {
- console.log('[SW] Push received:', event);
-
- let notificationData = {
- title: 'Новое уведомление',
- body: '',
- ...DEFAULT_NOTIFICATION_OPTIONS,
- data: {}
- };
-
- if (event.data) {
- try {
- const payload = event.data.json();
- console.log('[SW] Push payload:', payload);
-
- notificationData = {
- ...notificationData,
- title: payload.title || notificationData.title,
- body: payload.body || payload.message || '',
- icon: payload.icon || notificationData.icon,
- badge: payload.badge || notificationData.badge,
- tag: payload.tag || `notification-${Date.now()}`,
- data: payload.data || {}
- };
-
- // Дополнительные опции
- if (payload.image) {
- notificationData.image = payload.image;
- }
- if (payload.actions) {
- notificationData.actions = payload.actions;
- }
- if (payload.requireInteraction !== undefined) {
- notificationData.requireInteraction = payload.requireInteraction;
- }
-
- } catch (e) {
- // Если данные не JSON, используем как текст
- console.log('[SW] Push data is not JSON, using as text');
- notificationData.body = event.data.text();
- }
- }
-
- // Показываем уведомление
- const promiseChain = self.registration.showNotification(
- notificationData.title,
- {
- body: notificationData.body,
- icon: notificationData.icon,
- badge: notificationData.badge,
- tag: notificationData.tag,
- data: notificationData.data,
- vibrate: notificationData.vibrate,
- requireInteraction: notificationData.requireInteraction,
- renotify: notificationData.renotify,
- image: notificationData.image,
- actions: notificationData.actions,
- }
- );
-
- event.waitUntil(promiseChain);
-});
-
-/**
- * Обработка клика по уведомлению
- */
-self.addEventListener('notificationclick', (event) => {
- console.log('[SW] Notification click:', event);
-
- event.notification.close();
-
- // Получаем URL для перехода
- const notificationData = event.notification.data || {};
- let urlToOpen = notificationData.url || '/';
-
- // Обработка действий (action buttons)
- if (event.action) {
- console.log('[SW] Action clicked:', event.action);
- // Можно добавить обработку разных действий
- if (notificationData.actions && notificationData.actions[event.action]) {
- urlToOpen = notificationData.actions[event.action].url || urlToOpen;
- }
- }
-
- // Открываем или фокусируем окно
- event.waitUntil(
- self.clients.matchAll({ type: 'window', includeUncontrolled: true })
- .then((clientList) => {
- // Ищем уже открытое окно с нашим сайтом
- for (const client of clientList) {
- const clientUrl = new URL(client.url);
- const targetUrl = new URL(urlToOpen, self.location.origin);
-
- // Если найдено окно с тем же origin - фокусируем и переходим
- if (clientUrl.origin === targetUrl.origin && 'focus' in client) {
- return client.navigate(urlToOpen).then(() => client.focus());
- }
- }
-
- // Если нет открытого окна - открываем новое
- if (self.clients.openWindow) {
- return self.clients.openWindow(urlToOpen);
- }
- })
- );
-});
-
-/**
- * Обработка закрытия уведомления без клика
- */
-self.addEventListener('notificationclose', (event) => {
- console.log('[SW] Notification closed:', event.notification.tag);
-
- // Можно отправить на сервер информацию о закрытии
- // Например, для аналитики
- const notificationData = event.notification.data || {};
- if (notificationData.notification_id) {
- // fetch('/api/v1/notifications/' + notificationData.notification_id + '/dismissed/', {
- // method: 'POST',
- // credentials: 'include'
- // }).catch(() => {});
- }
-});
-
-/**
- * Получение сообщений от основной страницы
- */
-self.addEventListener('message', (event) => {
- console.log('[SW] Message received:', event.data);
-
- if (event.data && event.data.type === 'SKIP_WAITING') {
- self.skipWaiting();
- }
-});
-
-console.log('[SW] Service Worker loaded, version:', SW_VERSION);
diff --git a/backend/test_notification_flow.py b/backend/test_notification_flow.py
deleted file mode 100644
index 674cf325..00000000
--- a/backend/test_notification_flow.py
+++ /dev/null
@@ -1,146 +0,0 @@
-#!/usr/bin/env python
-"""
-Тест полного цикла уведомлений
-
-Проверяет:
-1. Создание уведомления через notify.send()
-2. Срабатывание post_save сигнала
-3. Отправку задач в Celery
-4. Наличие уведомления в БД
-5. Выполнение задач Celery
-"""
-
-import django
-import os
-import sys
-import time
-
-# Setup Django
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
-sys.path.insert(0, os.path.dirname(__file__))
-django.setup()
-
-from django.contrib.auth import get_user_model
-from notifications.signals import notify
-from notifications.models import Notification
-
-User = get_user_model()
-
-
-def test_notification_creation():
- """Тест создания уведомления"""
-
- print("\n" + "="*60)
- print("ТЕСТ СИСТЕМЫ УВЕДОМЛЕНИЙ")
- print("="*60)
-
- # 1. Находим двух пользователей
- print("\n1. Поиск пользователей...")
- users = User.objects.filter(is_active=True)[:2]
-
- if users.count() < 2:
- print("❌ Нужно минимум 2 активных пользователя")
- return
-
- sender = users[0]
- recipient = users[1]
-
- print(f" ✅ Отправитель: {sender.username} (ID: {sender.id})")
- print(f" ✅ Получатель: {recipient.username} (ID: {recipient.id})")
-
- # 2. Проверяем настройки получателя
- print("\n2. Проверка настроек получателя...")
- try:
- prefs = recipient.channel_preferences
- print(f" ✅ web_enabled: {prefs.web_enabled}")
- print(f" ✅ email_enabled: {prefs.email_enabled}")
- print(f" ✅ push_enabled: {prefs.push_enabled}")
- print(f" ✅ email_frequency: {prefs.email_frequency}")
- print(f" ✅ DND период: {prefs.is_in_dnd_period()}")
- except Exception as e:
- print(f" ⚠️ Нет настроек (будут созданы): {e}")
-
- # 3. Подсчет уведомлений до создания
- print("\n3. Подсчет уведомлений перед тестом...")
- count_before = Notification.objects.count()
- print(f" Всего уведомлений в БД: {count_before}")
-
- # 4. Создаем уведомление
- print("\n4. Создание уведомления через notify.send()...")
-
- try:
- result = notify.send(
- sender=sender,
- recipient=recipient,
- verb='test_notification',
- description=f'🧪 Тестовое уведомление от {sender.username}',
- action_url='/test/',
- data={
- 'test': True,
- 'timestamp': time.time(),
- }
- )
-
- print(f" ✅ notify.send() выполнен")
- print(f" Результат: {result}")
-
- except Exception as e:
- print(f" ❌ Ошибка при создании: {e}")
- import traceback
- traceback.print_exc()
- return
-
- # 5. Проверяем что уведомление создано в БД
- print("\n5. Проверка создания в БД...")
- time.sleep(0.1) # Небольшая задержка для сохранения
-
- count_after = Notification.objects.count()
- new_notifications = count_after - count_before
-
- print(f" Всего уведомлений в БД: {count_after}")
- print(f" Создано новых: {new_notifications}")
-
- if new_notifications == 0:
- print(" ❌ Уведомление не создано в БД!")
- return
-
- # 6. Получаем созданное уведомление
- print("\n6. Детали созданного уведомления...")
- notification = Notification.objects.filter(recipient=recipient).order_by('-id').first()
-
- if notification:
- print(f" ✅ ID: {notification.id}")
- print(f" ✅ verb: {notification.verb}")
- print(f" ✅ description: {notification.description}")
- print(f" ✅ recipient: {notification.recipient.username}")
- print(f" ✅ actor: {notification.actor}")
- print(f" ✅ unread: {notification.unread}")
- print(f" ✅ timestamp: {notification.timestamp}")
- else:
- print(" ❌ Уведомление не найдено!")
- return
-
- # 7. Ждем выполнения Celery задач
- print("\n7. Ожидание выполнения Celery задач...")
- print(" ⏳ Ждем 2 секунды...")
- time.sleep(2)
-
- # 8. Проверяем логи Celery
- print("\n8. Результаты:")
- print(" 📋 Проверьте логи Celery worker на наличие:")
- print(f" - Task notifications.send_websocket_notification[...] received")
- print(f" - Notification {notification.id} not found (если есть race condition)")
- print(f" - Task ... succeeded (если всё ОК)")
-
- print("\n" + "="*60)
- print("ТЕСТ ЗАВЕРШЕН")
- print("="*60)
- print("\nПроверьте:")
- print("1. Логи Celery worker")
- print("2. WebSocket соединение в браузере")
- print("3. Появилось ли уведомление в UI")
- print()
-
-
-if __name__ == '__main__':
- test_notification_creation()
diff --git a/backend/test_notifications.py b/backend/test_notifications.py
deleted file mode 100644
index 8fdf8bb3..00000000
--- a/backend/test_notifications.py
+++ /dev/null
@@ -1,237 +0,0 @@
-#!/usr/bin/env python
-"""
-Тестовый скрипт для проверки системы уведомлений v2.0
-
-Использование:
- python test_notifications.py
-
-Что тестируется:
-- Создание уведомления через notify.send()
-- Сохранение в БД
-- Отправка через Celery задачи (WebSocket, Email, Push)
-- Channel preferences
-- API endpoints
-"""
-import os
-import django
-
-# Настройка Django
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
-django.setup()
-
-from django.contrib.auth import get_user_model
-from notifications.signals import notify
-from notifications.models import Notification, UserChannelPreferences
-
-User = get_user_model()
-
-
-def test_notify_send():
- """Тест создания уведомления через notify.send()"""
- print("\n" + "="*80)
- print("ТЕСТ 1: Создание уведомления через notify.send()")
- print("="*80)
-
- try:
- # Получаем первых двух пользователей
- users = User.objects.all()[:2]
- if len(users) < 2:
- print("❌ Нужно минимум 2 пользователя в БД")
- return False
-
- sender_user = users[0]
- recipient_user = users[1]
-
- print(f"📤 Отправитель: {sender_user.username}")
- print(f"📥 Получатель: {recipient_user.username}")
-
- # Создаем уведомление
- notification = notify.send(
- sender=sender_user,
- recipient=recipient_user,
- verb='liked',
- description=f'{sender_user.get_full_name() or sender_user.username} liked your post',
- action_url='/posts/123/',
- )
-
- if isinstance(notification, list):
- notification = notification[0]
-
- print(f"✅ Уведомление создано: ID={notification.id}")
- print(f" Verb: {notification.verb}")
- print(f" Description: {notification.description}")
- print(f" Actor: {notification.actor}")
- print(f" Unread: {notification.unread}")
-
- return True
-
- except Exception as e:
- print(f"❌ Ошибка: {e}")
- import traceback
- traceback.print_exc()
- return False
-
-
-def test_channel_preferences():
- """Тест настроек каналов"""
- print("\n" + "="*80)
- print("ТЕСТ 2: UserChannelPreferences")
- print("="*80)
-
- try:
- user = User.objects.first()
- if not user:
- print("❌ Нет пользователей в БД")
- return False
-
- print(f"👤 Пользователь: {user.username}")
-
- # Получаем или создаем настройки
- prefs, created = UserChannelPreferences.objects.get_or_create(user=user)
-
- if created:
- print("✅ Созданы новые настройки с дефолтными значениями")
- else:
- print("✅ Настройки уже существуют")
-
- print(f" Web: {prefs.web_enabled}")
- print(f" Email: {prefs.email_enabled} (frequency: {prefs.email_frequency})")
- print(f" Push: {prefs.push_enabled}")
- print(f" DND: {prefs.dnd_enabled} ({prefs.dnd_start_time} - {prefs.dnd_end_time})")
-
- # Проверяем is_in_dnd_period()
- is_dnd = prefs.is_in_dnd_period()
- print(f" Сейчас в DND: {is_dnd}")
-
- return True
-
- except Exception as e:
- print(f"❌ Ошибка: {e}")
- import traceback
- traceback.print_exc()
- return False
-
-
-def test_queryset_methods():
- """Тест методов QuerySet"""
- print("\n" + "="*80)
- print("ТЕСТ 3: QuerySet методы")
- print("="*80)
-
- try:
- user = User.objects.first()
- if not user:
- print("❌ Нет пользователей в БД")
- return False
-
- print(f"👤 Пользователь: {user.username}")
-
- # Получаем статистику
- total = Notification.objects.filter(recipient=user).count()
- unread = Notification.objects.filter(recipient=user).unread().count()
- read_count = Notification.objects.filter(recipient=user).read().count()
-
- print(f"📊 Статистика:")
- print(f" Всего: {total}")
- print(f" Непрочитанных: {unread}")
- print(f" Прочитанных: {read_count}")
-
- # Последние 5 уведомлений
- recent = Notification.objects.filter(
- recipient=user
- ).order_by('-timestamp')[:5]
-
- if recent:
- print(f"\n📬 Последние {recent.count()} уведомлений:")
- for n in recent:
- status = "🔵" if n.unread else "⚪"
- print(f" {status} [{n.verb}] {n.description[:50]}... ({n.timesince()} ago)")
-
- return True
-
- except Exception as e:
- print(f"❌ Ошибка: {e}")
- import traceback
- traceback.print_exc()
- return False
-
-
-def test_mark_as_read():
- """Тест отметки как прочитанного"""
- print("\n" + "="*80)
- print("ТЕСТ 4: Отметка как прочитанного")
- print("="*80)
-
- try:
- user = User.objects.first()
- if not user:
- print("❌ Нет пользователей в БД")
- return False
-
- # Находим непрочитанное уведомление
- notification = Notification.objects.filter(
- recipient=user,
- unread=True
- ).first()
-
- if not notification:
- print("⚠️ Нет непрочитанных уведомлений")
- return True
-
- print(f"📬 Уведомление ID={notification.id}: unread={notification.unread}")
-
- # Отмечаем как прочитанное
- notification.mark_as_read()
-
- # Перезагружаем из БД
- notification.refresh_from_db()
-
- print(f"✅ После mark_as_read(): unread={notification.unread}")
- print(f" timestamp_read: {notification.timestamp_read}")
-
- return True
-
- except Exception as e:
- print(f"❌ Ошибка: {e}")
- import traceback
- traceback.print_exc()
- return False
-
-
-def main():
- """Запуск всех тестов"""
- print("\n" * 2)
- print("╔" + "="*78 + "╗")
- print("║" + " "*20 + "ТЕСТИРОВАНИЕ NOTIFICATIONS V2.0" + " "*25 + "║")
- print("╚" + "="*78 + "╝")
-
- results = []
-
- # Запускаем тесты
- results.append(("notify.send()", test_notify_send()))
- results.append(("UserChannelPreferences", test_channel_preferences()))
- results.append(("QuerySet методы", test_queryset_methods()))
- results.append(("mark_as_read()", test_mark_as_read()))
-
- # Итоги
- print("\n" + "="*80)
- print("ИТОГИ")
- print("="*80)
-
- for test_name, result in results:
- status = "✅ PASSED" if result else "❌ FAILED"
- print(f"{status}: {test_name}")
-
- passed = sum(1 for _, r in results if r)
- total = len(results)
-
- print(f"\n{'='*80}")
- print(f"Пройдено: {passed}/{total} тестов")
- print(f"{'='*80}\n")
-
- return all(r for _, r in results)
-
-
-if __name__ == "__main__":
- success = main()
- exit(0 if success else 1)