удалил лишнего
This commit is contained in:
parent
d83aae6791
commit
abcb264e38
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
|
||||
Loading…
x
Reference in New Issue
Block a user