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

Загружаем сообщения...

-
- - -
- - -
...
- - -
-
- - - -
-
- -
-
-
-
-``` - -#### Data-атрибуты контейнера: - -```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)