Merge: реструктуризация notifications во всех приложениях
Объединяет изменения из feature/optimize-notifications-usage: - requests_app: модульная структура notifications - procurement: удален дублирующий WebSocket модуль - feed: добавлены уведомления о лайках - communications: оптимизация для упоминаний и ответов - documents: поддержка bulk режима для массовых рассылок - calendar_app: отслеживание изменений событий Все приложения теперь используют единую архитектуру notifications.
This commit is contained in:
commit
7cfb899711
246
BUDGET_SYSTEM.md
246
BUDGET_SYSTEM.md
@ -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)
|
||||
```
|
||||
@ -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 г.
|
||||
@ -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 }}`
|
||||
@ -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. Бесконечная прокрутка из коробки
|
||||
@ -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`
|
||||
@ -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`
|
||||
@ -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. ✅ Работает для всех типов сообщений (свои и чужие)
|
||||
|
||||
---
|
||||
|
||||
**Статус:** ✅ Исправлено
|
||||
**Тестирование:** Требуется проверка в браузере
|
||||
@ -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 г.
|
||||
@ -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
@ -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
|
||||
@ -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` для деталей.
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
# Фильтрация по правам доступа
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BotsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'bots'
|
||||
@ -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)
|
||||
@ -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()
|
||||
@ -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': 'Подписчики ботов',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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 "-"})'
|
||||
@ -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()
|
||||
@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@ -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,
|
||||
# })
|
||||
@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@ -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: регистрация предикатов и правил доступа
|
||||
|
||||
|
||||
37
backend/calendar_app/notifications/__init__.py
Normal file
37
backend/calendar_app/notifications/__init__.py
Normal 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',
|
||||
]
|
||||
126
backend/calendar_app/notifications/config.py
Normal file
126
backend/calendar_app/notifications/config.py
Normal 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
|
||||
])
|
||||
174
backend/calendar_app/notifications/handlers.py
Normal file
174
backend/calendar_app/notifications/handlers.py
Normal 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,
|
||||
}
|
||||
)
|
||||
75
backend/calendar_app/notifications/signals.py
Normal file
75
backend/calendar_app/notifications/signals.py
Normal 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)
|
||||
@ -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("Событие не найдено!")
|
||||
@ -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
|
||||
@ -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 │
|
||||
│ ↳ В ответ на сообщение из │
|
||||
│ "Общий чат компании" │
|
||||
├─────────────────────────────────┤
|
||||
│ > Исходное сообщение... │
|
||||
│ Ответ... │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Контекстное меню сообщения (расширенное)
|
||||
- Ответить
|
||||
- Ответить в другом чате →
|
||||
- Переслать →
|
||||
- В избранное
|
||||
- В другой чат...
|
||||
- В несколько чатов...
|
||||
- Отправить в чужой чат →
|
||||
- Скопировать
|
||||
- Редактировать (свои)
|
||||
- Удалить (свои/админ)
|
||||
- Закрепить (админ)
|
||||
- Пожаловаться
|
||||
|
||||
### Права и разрешения (админ панель чата)
|
||||
- Настройки чата:
|
||||
- Кто может писать (все/только участники/только админы)
|
||||
- Разрешить внешние сообщения (да/нет/с модерацией)
|
||||
- Кто может приглашать участников
|
||||
- Управление внешними отправителями:
|
||||
- Список пользователей с правами
|
||||
- Добавить/удалить права
|
||||
- Установить срок действия
|
||||
- Модерация ожидающих сообщений
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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}
|
||||
@ -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 'Чат'
|
||||
43
backend/communications/notifications/__init__.py
Normal file
43
backend/communications/notifications/__init__.py
Normal 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',
|
||||
]
|
||||
159
backend/communications/notifications/config.py
Normal file
159
backend/communications/notifications/config.py
Normal 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 'Чат'
|
||||
220
backend/communications/notifications/handlers.py
Normal file
220
backend/communications/notifications/handlers.py
Normal 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,
|
||||
}
|
||||
)
|
||||
62
backend/communications/notifications/signals.py
Normal file
62
backend/communications/notifications/signals.py
Normal 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)
|
||||
@ -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),
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@ -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"
|
||||
),
|
||||
]
|
||||
@ -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:
|
||||
@ -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: регистрация предикатов и правил доступа
|
||||
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
35
backend/documents/notifications/__init__.py
Normal file
35
backend/documents/notifications/__init__.py
Normal 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',
|
||||
]
|
||||
114
backend/documents/notifications/config.py
Normal file
114
backend/documents/notifications/config.py
Normal 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 'Администратор'
|
||||
404
backend/documents/notifications/handlers.py
Normal file
404
backend/documents/notifications/handlers.py
Normal 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,
|
||||
},
|
||||
)
|
||||
190
backend/documents/notifications/signals.py
Normal file
190
backend/documents/notifications/signals.py
Normal 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)
|
||||
@ -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}"
|
||||
# )
|
||||
@ -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()
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
@ -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"))
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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",
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
"""
|
||||
Тесты для employees приложения.
|
||||
"""
|
||||
@ -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
|
||||
# Часть может быть успешной даже если есть ошибки
|
||||
@ -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"),
|
||||
]
|
||||
@ -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"),
|
||||
]
|
||||
@ -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
@ -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:
|
||||
|
||||
@ -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")),
|
||||
]
|
||||
|
||||
# Публичные медиа файлы (без проверки прав) - только в режиме разработки
|
||||
|
||||
@ -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: регистрация предикатов и правил доступа
|
||||
|
||||
@ -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}),
|
||||
}
|
||||
@ -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
|
||||
29
backend/feed/notifications/__init__.py
Normal file
29
backend/feed/notifications/__init__.py
Normal 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',
|
||||
]
|
||||
93
backend/feed/notifications/config.py
Normal file
93
backend/feed/notifications/config.py
Normal 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] + '...'
|
||||
162
backend/feed/notifications/handlers.py
Normal file
162
backend/feed/notifications/handlers.py
Normal 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 []
|
||||
44
backend/feed/notifications/signals.py
Normal file
44
backend/feed/notifications/signals.py
Normal 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)
|
||||
@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@ -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"),
|
||||
]
|
||||
@ -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)
|
||||
@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@ -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"),
|
||||
]
|
||||
@ -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)
|
||||
@ -1 +0,0 @@
|
||||
default_app_config = 'hikcentral.apps.HikcentralConfig'
|
||||
@ -1,3 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@ -1,10 +0,0 @@
|
||||
# hikcentral/apps.py
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HikcentralConfig(AppConfig):
|
||||
name = 'hikcentral'
|
||||
|
||||
def ready(self):
|
||||
import hikcentral.signals
|
||||
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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}"
|
||||
@ -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()
|
||||
@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@ -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
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user