Merge: реструктуризация notifications во всех приложениях

Объединяет изменения из feature/optimize-notifications-usage:
- requests_app: модульная структура notifications
- procurement: удален дублирующий WebSocket модуль
- feed: добавлены уведомления о лайках
- communications: оптимизация для упоминаний и ответов
- documents: поддержка bulk режима для массовых рассылок
- calendar_app: отслеживание изменений событий

Все приложения теперь используют единую архитектуру notifications.
This commit is contained in:
lizerk 2026-03-10 14:21:49 +03:00
commit 7cfb899711
129 changed files with 3729 additions and 14487 deletions

View File

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

View File

@ -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
<div class="container chat-root" id="chatDetailApp" data-chat-id="..." data-user-id="...">
<div class="row gx-0">
<div class="col-12 chat-col">
<!-- 1. Заголовок чата -->
<div class="section-header">
<!-- Иконка, название, кнопки управления -->
</div>
<!-- 2. Корпус чата -->
<article class="card chat-box">
<!-- 2.1 Предупреждения (блокировка, etc) -->
<div class="alert">...</div>
<!-- 2.2 Кнопка "вниз" -->
<button id="scrollBtn" class="scroll-to-bottom">...</button>
<!-- 2.3 Контейнер сообщений -->
<div id="chatScroll" class="chat-scroll"
data-me-id="..."
data-chat-id="..."
data-fetch-url="..."
data-oldest-id="..."
data-has-more="...">
<!-- Индикатор загрузки истории (при скролле вверх) -->
<div class="history-loader d-none">...</div>
<!-- Индикатор первоначальной загрузки -->
<div class="initial-loader" id="initialLoader">
<div class="spinner-border">...</div>
<p>Загружаем сообщения...</p>
</div>
<!-- Сообщения добавляются через JavaScript -->
</div>
<!-- 2.4 Индикатор "печатает..." -->
<div id="typing" class="d-none">...</div>
<!-- 2.5 Композер (форма ввода) -->
<div class="chat-composer">
<form id="chatForm">
<!-- Кнопки вложений, эмодзи -->
<!-- Поле ввода textarea -->
<!-- Кнопка отправки -->
</form>
</div>
</article>
</div>
</div>
</div>
```
#### Data-атрибуты контейнера:
```html
<div id="chatDetailApp"
data-chat-id="{{ chat.id }}"
data-user-id="{{ user.id }}"
data-user-name="{{ user.get_full_name }}"
data-user-avatar="{{ user.avatar.url }}"
data-upload-url="{% url 'api:v1:upload_message' %}"
data-messages-url="{% url 'api:v1:chat_messages' chat.pk %}"
data-mark-read-url="{% url 'communications:chat_mark_read' chat.pk %}"
data-edit-url-template="/api/v1/communications/messages/{id}/edit/">
```
**Важно:** Сообщения из контекста `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
<div class="d-flex mb-3 msg"
data-id="123"
data-message-id="123"
data-ts="1701734400000"
data-author-id="5"
data-is-edited="false"
data-reactions='{"👍": 2, "❤️": 1}'>
<!-- Аватар (только для чужих сообщений слева) -->
<a class="me-2" href="/employees/5/">
<span class="mini-ava border">
<img src="/media/avatars/user.jpg" alt="">
</span>
</a>
<div class="d-flex flex-column">
<!-- Имя и время -->
<div class="small text-secondary">
<a href="/employees/5/">Иван Иванов</a> · <time>12:30</time>
</div>
<!-- Bubble с содержимым -->
<div class="bubble bubble-other">
<!-- Индикатор пересылки (если есть) -->
<div class="forwarded-indicator">...</div>
<!-- Ответ на сообщение (если есть) -->
<div class="reply-reference">...</div>
<!-- Текст сообщения -->
Привет, как дела?
<!-- Индикатор редактирования -->
<span class="message-edited-indicator">(изменено)</span>
<!-- Голосование (если есть) -->
<div class="poll-widget">...</div>
<!-- Вложения (если есть) -->
<div class="message-attachments">...</div>
</div>
<!-- Реакции -->
<div class="message-reactions-wrapper">...</div>
</div>
<!-- Аватар справа для своих сообщений -->
<a class="ms-2" href="/employees/profile/">...</a>
</div>
```
---
## 🔄 Поток данных
### 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 г.

View File

@ -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/... → добавляем домен #}
<img src="{{ MEDIA_URL_DYNAMIC }}{{ img_url|slice:"7:" }}" alt="...">
{% elif img_url|slice:":1" == "/" %}
{# Абсолютный путь /... → добавляем схему и домен #}
<img src="{{ request.scheme }}://{{ request.get_host }}{{ img_url }}" alt="...">
{% else %}
{# Уже полный URL или внешняя ссылка → используем как есть #}
<img src="{{ img_url }}" alt="...">
{% 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 }}`

View File

@ -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
<article class="feed-post" id="post-123" data-post-card>
<header class="card-header">
<!-- Аватар + Имя автора + Дата -->
</header>
<div class="feed-img">
<!-- Изображение поста -->
</div>
<div class="card-body">
<!-- Заголовок + Текст -->
</div>
<footer class="feed-ft">
<!-- Лайки + Комментарии -->
</footer>
</article>
```
## 🐛 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. Бесконечная прокрутка из коробки

View File

@ -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
<h1>403 Forbidden</h1>
<p>Регистрация доступна только из локальной сети.</p>
<p>Ваш IP: 203.0.113.50</p>
```
### Для 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`

View File

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

View File

@ -1,198 +0,0 @@
# Исправление: Поддержка переносов строк в сообщениях
**Дата:** 4 декабря 2025 г.
**Проблема:** Сообщения не отображали переносы строк (многострочный текст показывался одной строкой)
---
## 🔴 Проблема
При отправке многострочных сообщений в чате (с нажатием Enter для переноса строки), текст отображался одной строкой, игнорируя все переносы.
### Пример:
**Ввод пользователя:**
```
Строка 1
Строка 2
Строка 3
```
**Отображалось как:**
```
Строка 1 Строка 2 Строка 3
```
---
## 🔍 Анализ
### 1. Рендеринг сообщений (`messageRenderer.js`)
```javascript
// Строка 210
${msg.content ? `<div>${this.escapeHtml(msg.content)}</div>` : ''}
// Метод 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` в `<br>` в JavaScript
```javascript
// В messageRenderer.js
formatContent(text) {
return this.escapeHtml(text).replace(/\n/g, '<br>');
}
// Использование:
${msg.content ? `<div>${this.formatContent(msg.content)}</div>` : ''}
```
**Недостатки:**
- ❌ Не сохраняет множественные пробелы
- ❌ Требует изменения 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. ✅ Работает для всех типов сообщений (свои и чужие)
---
**Статус:** ✅ Исправлено
**Тестирование:** Требуется проверка в браузере

View File

@ -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 `<div class="d-flex mb-3 msg...">${this.buildMessageInnerHtml(msg, isOwn)}</div>`;
}
// Внутренний HTML без обёртки для DOM-элементов
buildMessageInnerHtml(msg, isOwn) {
// Все шаблоны в одну строку без переносов
return `${avatarHtml}<div class="d-flex flex-column">...</div>${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 ? `
<div class="forwarded-indicator small mb-2">
<i class="bi-arrow-90deg-right me-2"></i>
<div>
<div>Переслано от <strong>${author}</strong></div>
</div>
</div>` : '';
```
**ПОСЛЕ:**
```javascript
const forwardedHtml = msg.is_forwarded ? `<div class="forwarded-indicator small mb-2 d-flex align-items-center"><i class="bi-arrow-90deg-right me-2"></i><div><div>Переслано от <strong>${msg.forwarded_from.author_name || 'Пользователя'}</strong>${msg.forwarded_from.chat_name ? ` из «${msg.forwarded_from.chat_name}»` : ''}</div>${msg.forwarded_from.created_at ? `<div class="small opacity-75">${msg.forwarded_from.created_at}</div>` : ''}</div></div>` : '';
```
### 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 г.

View File

@ -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 | Руководитель + Финансы + Директор
```

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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` для деталей.

View File

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

View File

@ -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)
# Фильтрация по правам доступа

View File

@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@ -1,6 +0,0 @@
from django.apps import AppConfig
class BotsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'bots'

View File

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

View File

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

View File

@ -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': 'Подписчики ботов',
},
),
]

View File

@ -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),
),
]

View File

@ -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',
),
]

View File

@ -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'),
),
]

View File

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

View File

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

View File

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

View File

@ -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,
# })

View File

@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

View File

@ -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: регистрация предикатов и правил доступа

View File

@ -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',
]

View File

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

View File

@ -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,
}
)

View File

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

View File

@ -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("Событие не найдено!")

View File

@ -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/<id>/update/` - обновление названия/описания/аватара
- `POST /communications/api/chat/<id>/pin/` - закрепление/открепление чата
- `POST /communications/api/chat/<id>/notifications/` - управление уведомлениями
#### Действия с сообщениями
- `POST /communications/api/message/<id>/edit/` - редактирование с историей
- `POST /communications/api/message/<id>/delete/` - мягкое удаление
- `POST /communications/api/message/<id>/react/` - добавить реакцию
- `POST /communications/api/message/<id>/unreact/` - удалить реакцию
- `POST /communications/api/message/<id>/pin/` - закрепить сообщение
#### Вложения
- `POST /communications/api/attachment/upload/` - загрузка файлов
#### Пересылка и ответы
- `POST /communications/api/message/<id>/forward/` - пересылка с цепочкой
- `POST /communications/api/message/<id>/reply/` - ответ (inline/quote/thread)
#### Участники
- `POST /communications/api/chat/<id>/member/add/` - добавить участника
- `POST /communications/api/chat/<id>/member/remove/` - удалить участника
---
### 3. **WebSocket Consumers** (Этап 13) ✅
Обновлен `communications/consumers.py`:
#### ChatConsumer - обработка действий:
- `send_message` - отправка с поддержкой ответов
- `edit_message` - редактирование в реальном времени
- `delete_message` - удаление с уведомлением всех
- `add_reaction` / `remove_reaction` - реакции
- `typing` / `stop_typing` - индикатор набора текста
#### Real-time события:
- `chat.message` - новое сообщение
- `chat.message_edited` - сообщение изменено
- `chat.message_deleted` - сообщение удалено
- `chat.reaction_added` / `removed` - реакции обновлены
- `chat.user_typing` / `stopped_typing` - статус набора
#### Расширенная сериализация:
- Полная информация о сообщении
- Вложения с URL и превью
- Информация о пересылке
- Превью ответов
- Реакции всех пользователей
---
### 4. **Frontend (UI/UX)** (Этап 14) ✅
#### JavaScript модули:
**`chat-detail-enhanced.js`** - улучшенный интерфейс чата:
- Real-time обновления через WebSocket
- Редактирование сообщений с индикатором
- Удаление сообщений
- Быстрые реакции (6 эмодзи по наведению)
- Ответы на сообщения с превью
- Индикатор "печатает..."
- Автопереподключение WebSocket
- Отображение вложений и превью
- Информация о пересылке
**`chat-list-enhanced.js`** - улучшенный список чатов:
- Real-time обновление последних сообщений
- Закрепление чатов с перемещением наверх
- Контекстное меню (ПКМ):
- Закрепить/открепить
- Выключить уведомления
- Скрыть чат
- Отметить прочитанным
- Индикатор "печатает..." в списке
- Анимация обновления чатов
#### CSS стили:
**`chat-enhanced.css`** - стили для чата:
- Превью ответов с border-left
- Информация о пересылке
- Кнопки действий при наведении
- Стили реакций (активные/неактивные)
- Быстрые реакции на hover
- Отображение вложений
- Индикатор редактирования
- Значок закрепленного сообщения
- Системные сообщения
- Анимация новых сообщений
**`chat-list-enhanced.css`** - стили для списка:
- Контекстное меню
- Анимация обновления чата
- Стили закрепленных чатов (желтый фон)
- Значок закрепления
- Аватары чатов
- Групповые аватары
---
### 5. **Админ-панель** (Этап 12) ✅
Обновлен `communications/admin.py`:
- Админки для всех новых моделей
- Inline-редактирование вложений
- Inline-редактирование участников
- Фильтры и поиск
- Отображение статусов (edited, deleted, pinned)
---
## 🔧 Технические детали
### Миграции
Создано 12 миграций:
- `0006` - добавление полей в Chat
- `0007` - обновление типов чатов
- `0008` - модель ChatUserSettings
- `0009` - расширение Message (19 полей)
- `0010` - модель MessageAttachment
- `0011` - ForwardedMessage, MessageReply, ChatAccessPermission, CrossChatMessage
- `0012` - ChatMembership и обновление ChatReadState
### Зависимости
- Django Channels - WebSocket
- Pillow - обработка изображений
- Bootstrap 5 - UI компоненты
- Bootstrap Icons - иконки
### Структура файлов
```
communications/
├── models.py (обновлено - 10 моделей)
├── views.py (существующие view)
├── api_views.py (НОВОЕ - 15 эндпоинтов)
├── consumers.py (обновлено - real-time)
├── admin.py (обновлено - админки)
├── urls.py (обновлено - роуты API)
└── migrations/ (12 миграций)
static/
├── js/
│ ├── chat-detail-enhanced.js (НОВОЕ)
│ └── chat-list-enhanced.js (НОВОЕ)
└── css/
└── components/
├── chat-enhanced.css (НОВОЕ)
└── chat-list-enhanced.css (НОВОЕ)
```
---
## 📝 Использование
### Создание группового чата
```javascript
fetch('/communications/api/chat/create/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCsrfToken()
},
body: new URLSearchParams({
type: 'group',
name: 'Название группы',
description: 'Описание'
})
});
```
### Редактирование сообщения
```javascript
sendAction('edit_message', {
message_id: 123,
content: 'Новый текст'
});
```
### Добавление реакции
```javascript
sendAction('add_reaction', {
message_id: 123,
emoji: '👍'
});
```
### Закрепление чата
```javascript
fetch(`/communications/api/chat/${chatId}/pin/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCsrfToken()
},
body: 'pinned=true'
});
```
---
## 🎯 Следующие шаги (опционально)
### Возможные улучшения:
1. Упоминания (@username) с подсветкой и уведомлениями
2. Поиск по содержимому сообщений
3. Голосовые сообщения
4. Видеозвонки
5. Шифрование сообщений E2E
6. Экспорт истории чата
7. Боты и автоматизация
8. Rich text редактор (Markdown, форматирование)
9. Статусы пользователей (онлайн/офлайн/занят)
10. Push-уведомления
---
## ⚠️ Известные ограничения
1. **SQLite предупреждения** - deferrable constraints не поддерживаются (неблокирующее)
2. **Lint warnings** - несколько строк >79 символов (несущественно)
3. **UI требует подключения скриптов** - нужно добавить в шаблоны:
```django
{% block extra_js %}
<script src="{% static 'js/chat-detail-enhanced.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/components/chat-enhanced.css' %}">
{% endblock %}
```
---
## 📊 Статистика
- **Модели**: 10 (4 новые, 6 обновленных)
- **Миграции**: 12
- **API эндпоинты**: 15
- **WebSocket события**: 10 типов
- **JS файлы**: 2 новых модуля (~800 строк)
- **CSS файлы**: 2 новых файла (~400 строк)
- **Общий объем кода**: ~3500 строк
---
## ✅ Готовность к продакшену
### Выполнено:
- ✅ Все миграции применены
- ✅ Модели протестированы (создание/обновление)
- ✅ API эндпоинты реализованы
- ✅ WebSocket consumers обновлены
- ✅ Frontend код написан
- ✅ Админ-панель расширена
### Требуется:
- ⏳ Подключить новые JS/CSS файлы в шаблоны
- ⏳ Интеграционное тестирование
- ⏳ Тестирование производительности
- ⏳ Настройка прав доступа
- ⏳ Документация для пользователей
---
Автор: GitHub Copilot
Дата: 20 ноября 2025
Версия: 1.0

View File

@ -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 │
│ ↳ В ответ на сообщение из │
│ "Общий чат компании" │
├─────────────────────────────────┤
│ > Исходное сообщение... │
│ Ответ... │
└─────────────────────────────────┘
```
### Контекстное меню сообщения (расширенное)
- Ответить
- Ответить в другом чате →
- Переслать →
- В избранное
- В другой чат...
- В несколько чатов...
- Отправить в чужой чат →
- Скопировать
- Редактировать (свои)
- Удалить (свои/админ)
- Закрепить (админ)
- Пожаловаться
### Права и разрешения (админ панель чата)
- Настройки чата:
- Кто может писать (все/только участники/только админы)
- Разрешить внешние сообщения (да/нет/с модерацией)
- Кто может приглашать участников
- Управление внешними отправителями:
- Список пользователей с правами
- Добавить/удалить права
- Установить срок действия
- Модерация ожидающих сообщений

View File

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

View File

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

View File

@ -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 'Чат'

View File

@ -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',
]

View File

@ -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 'Чат'

View File

@ -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,
}
)

View File

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

View File

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

View File

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

View File

@ -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/<int:pk>/",
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/<int:employee_pk>/",
views.start_private_chat,
name="start_private_chat"
),
path(
"chats/<int:pk>/mark-read/",
views.chat_mark_read,
name="chat_mark_read"
),
path(
"start/private/<int:employee_pk>/",
views.start_private_chat,
name="start_private_chat"
),
]

View File

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

View File

@ -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: регистрация предикатов и правил доступа

View File

@ -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,
},
)

View File

@ -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',
]

View File

@ -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 'Администратор'

View File

@ -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,
},
)

View File

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

View File

@ -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}"
# )

View File

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

View File

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

View File

@ -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/<int:pk>/', acknowledge_document, name='acknowledge'),
]

View File

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

View File

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

View File

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

View File

@ -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:<mime>;base64,<payload>' или 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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
"""
Тесты для employees приложения.
"""

View File

@ -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
# Часть может быть успешной даже если есть ошибки

View File

@ -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("<int:pk>/", views_front.employee_profile, name="employee_detail"),
path("<int:pk>/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/<int:pk>/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/<int:pk>/roles/edit/",
views_front.edit_department_role,
name="edit_department_role",
),
path("departments/", views_front.department_list, name="department_list"),
path(
"departments/<int:pk>/", views_front.department_detail, name="department_detail"
),
path(
"departments/<int:pk>/edit/",
views_front.department_edit,
name="department_edit",
),
path(
"departments/<int:pk>/set-head/",
views_front.department_set_head,
name="department_set_head",
),
path(
"<int:emp_id>/departments/<int:dept_id>/set-role/",
views_front.set_member_role,
name="set_member_role",
),
path(
"departments/<int:pk>/add-member/",
views_front.department_add_member,
name="department_add_member",
),
path(
"departments/<int:pk>/remove-member/",
views_front.department_remove_member,
name="department_remove_member",
),
# --- Навыки ---
path("<int:pk>/skill/add/", views_front.skill_add, name="skill_add"),
path("<int:pk>/skill/remove/", views_front.skill_remove, name="skill_remove"),
# --- Должности ---
path(
"positions/create/", views_front.position_create_front, name="position_create"
),
path(
"positions/<int:pos_id>/update/",
views_front.position_update_front,
name="position_update",
),
path(
"<int:emp_id>/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/<int:pos_id>/", views_front.position_detail_front, name="position_detail"),
]

View File

@ -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/<uidb64>/<token>/", views_auth.PasswordResetConfirmView.as_view(), name="password_reset_confirm"),
path("reset/done/", views_auth.PasswordResetCompleteView.as_view(), name="password_reset_complete"),
]

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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")),
]
# Публичные медиа файлы (без проверки прав) - только в режиме разработки

View File

@ -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: регистрация предикатов и правил доступа

View File

@ -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}),
}

View File

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

View File

@ -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',
]

View File

@ -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] + '...'

View File

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

View File

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

View File

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

View File

@ -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/<int:pk>/", views_front.department_feed, name="department_feed"),
path("employees/<int:pk>/", views_front.employee_feed, name="employee_feed"),
path("post/<int:pk>/", views_front.post_detail, name="post_detail"),
path("post/<int:pk>/pin/", views_front.pin_post, name="pin_post"),
path("post/<int:pk>/like/", views_front.toggle_like, name="toggle_like"),
]

View File

@ -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=<id>&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)

View File

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

View File

@ -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"),
]

View File

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

View File

@ -1 +0,0 @@
default_app_config = 'hikcentral.apps.HikcentralConfig'

View File

@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@ -1,10 +0,0 @@
# hikcentral/apps.py
from django.apps import AppConfig
class HikcentralConfig(AppConfig):
name = 'hikcentral'
def ready(self):
import hikcentral.signals

View File

@ -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,
},
),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

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