- Создана структура docs/ с категориями: completed, guides, architecture, diagnostic, in-progress, reports - Перемещено 47+ MD файлов из корня проекта в docs/ - Удалено 6 устаревших/дублирующихся файлов - Создана структура backend/scripts/ для тестов и утилит - Перемещено 35 Python скриптов в backend/scripts/ - Создана структура backend/docs/ для технической документации - Перемещено 12 MD файлов backend в backend/docs/ - Перемещены LDAP тестовые данные в backend/tests/fixtures/ - Добавлены README файлы для навигации - Добавлены правила в .github/copilot-instructions.md для размещения документации Результат: - Корень проекта: только README.md - Корень backend/: только manage.py и Django приложения - Вся документация организована в структурированные папки
42 KiB
42 KiB
Архитектура рендеринга и наполнения страницы чата
📋 Оглавление
- Общая схема
- Серверная часть (Backend)
- Шаблон страницы (Template)
- JavaScript инициализация
- WebSocket соединение
- Рендеринг сообщений
- Поток данных
🎯 Общая схема
Страница чата работает по следующей архитектуре:
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)
Ответственность:
- Проверка прав доступа пользователя к чату
- Подготовка контекста для шаблона
- Установка заголовков для отключения кэширования
Ключевые методы:
def get_object(self):
"""Получает объект чата с проверкой прав доступа"""
# Фильтрует по типу чата: global/department/private/group
# Проверяет membership для приватных чатов
def get_context_data(self, **kwargs):
"""Подготавливает контекст для шаблона"""
# 1. Проверка доступа через _user_has_access()
# 2. Загрузка сообщений (последние 50, в обратном порядке)
# 3. Определение has_more (есть ли еще история)
# 4. Вычисление last_read_at и first_unread_id
# 5. Проверка прав на отправку сообщений
Контекст, передаваемый в шаблон:
{
'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 # Создатель объявления
}
Отключение кэширования:
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:
<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-атрибуты контейнера:
<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
Главный файл инициализации страницы чата.
Этапы инициализации:
// 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);
});
Инициализация компонентов:
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:
// 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 }
}));
Подключение:
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${proto}://${location.host}/ws/`;
const ws = new WebSocket(wsUrl);
Открытие чата:
function openChat(chatId, loadHistory = false) {
send({
action: 'open_chat',
chat_id: chatId,
load_history: loadHistory
});
}
📨 Загрузка сообщений через WebSocket
Backend: communications/consumers.py
ChatConsumer.connect():
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():
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))]
Формат сообщения:
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:
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 сообщения:
<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. ✅ Предотвращение дубликатов
const existingMessage = container.querySelector(`[data-message-id="${msg.id}"]`);
if (existingMessage) {
console.log('Message already exists, skipping:', msg.id);
return;
}
3. ✅ Автоматические разделители дней
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 г.