igor_nadein da7bf54c6c feat: Реализация полной функциональности опросов в чатах
- Исправлен PollSerializer для создания опросов с опциями
- Добавлено поле read_only_fields для корректной валидации
- Реализована отправка WebSocket уведомлений при создании опроса
- Исправлен импорт serialize_message из communications.serialization
- Добавлен обработчик poll_update в UserConsumer для WebSocket событий
- Исправлен эндпоинт /results/ для возврата user_voted_option_ids
- Добавлено поле voters в PollOptionSerializer для списка проголосовавших
- Реализована инициализация poll виджетов после рендера сообщений
- Добавлено игнорирование poll_update в notification-manager
- Исправлены стили для request list badges (height, word-break, flex)
- Восстановлены стили team-wheel (.wheel-focus, .scroll-arc) из git истории
- Реализовано отображение forwarded message info в messageRendererV2
- Множественные улучшения UI и UX для опросов

Fixes: создание опросов, голосование, отображение результатов, список проголосовавших
2026-01-22 01:33:42 +03:00

442 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% load static %}
{% load api_tags %}
{% block title %}Чат{% endblock %}
{% block content %}
<!-- Чат без лишних контейнеров -->
<div class="chat-page">
<!-- Шапка чата -->
<div class="chat-header">
<div class="chat-header-content">
<div class="chat-header-title">
{% if chat.avatar %}
<!-- Аватар чата с маленькой иконкой типа -->
<div class="chat-avatar-wrapper">
<img src="{{ chat.avatar.url }}" alt="{{ chat.name }}" class="chat-avatar">
<span class="chat-type-badge badge badge--avatar animate-in">
{% if chat.type == 'global' %}
<i class="bi-globe"></i>
{% elif chat.type == 'department' %}
<i class="bi-diagram-3"></i>
{% elif chat.type == 'announcement' %}
<i class="bi-megaphone"></i>
{% elif chat.type == 'group' %}
<i class="bi-people"></i>
{% elif chat.type == 'channel' %}
<i class="bi-broadcast"></i>
{% else %}
<i class="bi-person"></i>
{% endif %}
</span>
</div>
<h1>{{ chat.name|default:"Чат" }}</h1>
{% else %}
<!-- Иконка категории, если нет аватара -->
{% if chat.type == 'global' %}
<i class="bi-globe text-primary"></i>
<h1>{{ chat.name|default:"Глобальный чат" }}</h1>
{% elif chat.type == 'department' %}
<i class="bi-diagram-3 text-primary"></i>
<h1>{{ chat.department.name|default:chat.name }}</h1>
{% elif chat.type == 'announcement' %}
<i class="bi-megaphone text-warning"></i>
<h1>{{ chat.name|default:"Объявление" }}</h1>
{% else %}
<i class="bi-person text-primary"></i>
<h1>
{% if chat.name %}
{{ chat.name }}
{% else %}
{% for u in chat.participants.all %}
{% if u != user %}{{ u.get_full_name|default:u.username }}{% endif %}
{% endfor %}
{% endif %}
</h1>
{% endif %}
{% endif %}
</div>
<div class="chat-header-actions">
{% if chat.type == 'announcement' and user.is_staff %}
{% if chat.is_blocked %}
<button type="button" class="btn btn-success btn-sm" onclick="unblockAnnouncement({{ chat.id }})">
<i class="bi-unlock"></i>
</button>
{% else %}
<button type="button" class="btn btn-danger btn-sm" onclick="blockAnnouncement({{ chat.id }})">
<i class="bi-lock"></i>
</button>
{% endif %}
{% endif %}
<a href="{% url 'communications:chat_list' %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i>
</a>
</div>
</div>
</div>
<!-- Контейнер чата -->
<div class="chat-container"
id="chatDetailApp"
data-chat-id="{{ chat.id }}"
data-user-id="{{ user.id }}"
data-user-name="{{ user.get_full_name|default:user.username }}"
data-user-avatar="{% if user.avatar %}{{ user.avatar.url }}{% endif %}"
data-upload-url="/api/v1/communications/messages/upload/"
data-messages-url="/api/v1/communications/chats/{{ chat.pk }}/messages/"
data-mark-read-url="/api/v1/communications/chats/{{ chat.pk }}/mark-read/"
data-edit-url-template="/api/v1/communications/messages/{id}/"
data-last-read-timestamp="{{ chat.last_read_timestamp|default:'0' }}"
data-last-read-message-id="{% if chat.last_read_message_id %}{{ chat.last_read_message_id }}{% else %}0{% endif %}">
<!-- Предупреждение о блокировке -->
{% if chat.is_blocked %}
<div class="alert alert-danger m-3 mb-0" role="alert">
<div class="d-flex align-items-center">
<i class="bi-lock-fill fs-4 me-2"></i>
<div>
<strong>Это объявление заблокировано администратором</strong>
<br>
<small>
Заблокировано: {{ chat.blocked_at|date:"d.m.Y в H:i" }}
{% if chat.blocked_by %}
пользователем <strong>{{ chat.blocked_by.get_full_name|default:chat.blocked_by.username }}</strong>
{% endif %}
</small>
</div>
</div>
</div>
{% endif %}
<!-- Информация об объявлении -->
{% if chat.type == 'announcement' and not is_announcement_creator %}
<div class="alert alert-light border m-3 mb-0 py-2" role="alert">
<small class="text-secondary">
<i class="bi-info-circle me-1"></i>
Объявление от <strong>{{ chat.created_by.get_full_name|default:chat.created_by.username }}</strong>
</small>
</div>
{% endif %}
<!-- Лента сообщений -->
<div id="chatScroll"
class="chat-messages-scroll"
data-me-id="{{ user.id }}"
data-me-avatar="{% if user.avatar %}{{ user.avatar.url }}{% endif %}"
data-profile-url="{% url 'employees:profile' %}"
data-detail-url-template="{% url 'employees:employee_detail' 0 %}"
data-chat-id="{{ chat.id }}"
data-fetch-url="/api/v1/communications/chats/{{ chat.pk }}/messages/"
data-page-size="{{ messages_page_size }}"
data-oldest-id="{{ messages_oldest_id }}"
data-oldest-ts="{{ messages_oldest_ts }}"
data-has-more="{% if messages_has_more %}1{% else %}0{% endif %}"
data-last-read-ts="{% if last_read_at %}{{ last_read_at|date:'U' }}000{% endif %}">
<!-- Индикатор загрузки истории -->
<div class="history-loader d-none" data-history-loader>
<div class="spinner-border spinner-border-sm text-secondary" role="status" aria-hidden="true"></div>
<span class="small">Загружаем ранние сообщения…</span>
</div>
<!-- Индикатор первоначальной загрузки -->
<div class="initial-loader text-center py-5" id="initialLoader">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<p class="text-secondary">Загружаем сообщения...</p>
</div>
</div>
<!-- Индикатор печати -->
<div id="typing" class="chat-typing-indicator d-none">
<span class="typing-dots">
<span></span><span></span><span></span>
</span>
<span class="typing-text">Собеседник печатает…</span>
</div>
<!-- Кнопка «вниз» -->
<button id="scrollBtn" class="scroll-to-bottom" type="button" aria-label="Прокрутить вниз">
<i class="bi-arrow-down"></i>
<span class="unread-badge badge-hidden">0</span>
</button>
</div> <!-- .chat-container -->
<!-- Композер (вынесен из chat-container на уровень chat-page) -->
<div class="chat-composer-wrapper">
{% if can_send_messages %}
<form id="chatForm"
class="composer-wrap"
method="post"
action="/api/v1/communications/messages/upload/"
enctype="multipart/form-data"
data-chat-composer="1"
data-chat-id="{{ chat.id }}"
data-upload-url="/api/v1/communications/messages/upload/"
data-me-id="{{ user.id }}"
data-me-name="{{ user.get_full_name|default:user.username }}"
data-me-avatar="{% if user.avatar %}{{ user.avatar.url }}{% endif %}"
data-profile-url="{% url 'employees:profile' %}"
data-detail-url-template="{% url 'employees:employee_detail' 0 %}"
onsubmit="return false;">
{% csrf_token %}
<input type="hidden" name="chat_id" value="{{ chat.id }}">
<div class="message-field">
<!-- Кнопка вложений с выпадающим меню -->
<div class="dropdown message-attach">
<button type="button" class="btn btn-ghost btn-attachment message-icon-btn"
id="attachmentDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Прикрепить файл">
<i class="bi-paperclip"></i>
</button>
<ul class="dropdown-menu dropdown-menu-start" aria-labelledby="attachmentDropdown">
<li>
<a class="dropdown-item" href="#" id="attachDocument">
<i class="bi-file-earmark text-primary me-2"></i>
Документ
</a>
</li>
<li>
<a class="dropdown-item" href="#" id="attachImage">
<i class="bi-image text-success me-2"></i>
Фото или видео
</a>
</li>
<li>
<a class="dropdown-item" href="#" id="attachCamera">
<i class="bi-camera text-info me-2"></i>
Камера
</a>
</li>
<li>
<a class="dropdown-item" href="#" id="attachAudio">
<i class="bi-mic text-danger me-2"></i>
Аудио
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="#" id="createPoll">
<i class="bi-bar-chart text-warning me-2"></i>
Создать голосование
</a>
</li>
</ul>
</div>
<!-- Кнопка выбора эмодзи -->
<div class="dropdown message-emoji">
<button type="button" class="btn btn-ghost btn-emoji message-icon-btn"
id="emojiDropdown"
data-bs-toggle="dropdown"
data-emoji-toggle="1"
aria-expanded="false"
title="Вставить смайлик">
<i class="bi-emoji-smile"></i>
</button>
<div class="dropdown-menu dropdown-menu-start message-emoji-menu" aria-labelledby="emojiDropdown">
<emoji-picker data-emoji-picker class="chat-emoji-picker"></emoji-picker>
</div>
</div>
<!-- Поле ввода текста -->
<textarea name="content"
id="id_content"
class="form-control composer-textarea message-input"
rows="1"
placeholder="Напишите сообщение…"></textarea>
<!-- Кнопка отправки -->
<button type="submit" class="btn btn-primary btn-send message-send" title="Отправить">
<i class="bi-send-fill"></i>
</button>
</div>
<!-- Скрытые input для файлов -->
<input type="file" id="documentInput" class="d-none" accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar">
<input type="file" id="imageInput" class="d-none" accept="image/*,video/*" multiple>
<input type="file" id="cameraInput" class="d-none" accept="image/*" capture="environment">
<input type="file" id="audioInput" class="d-none" accept="audio/*">
<!-- Превью прикрепленных файлов -->
<div id="attachmentPreview" class="attachment-preview d-none"></div>
</form>
{% else %}
<div class="text-center text-secondary py-3">
<i class="bi-lock fs-4"></i>
<p class="mb-0 mt-2">
{% if chat.type == 'announcement' %}
Только автор объявления может публиковать сообщения
{% else %}
У вас нет прав для отправки сообщений в этот чат
{% endif %}
</p>
</div>
{% endif %}
</div> <!-- .chat-composer-wrapper -->
<!-- Модальное окно создания голосования -->
<div class="modal fade" id="pollModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi-bar-chart me-2"></i>Создать голосование
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="pollQuestion" class="form-label">Вопрос</label>
<input type="text" class="form-control" id="pollQuestion"
placeholder="Введите вопрос" maxlength="500">
</div>
<div class="mb-3">
<label class="form-label">Варианты ответа</label>
<div id="pollOptions">
<div class="input-group mb-2">
<span class="input-group-text">1</span>
<input type="text" class="form-control poll-option"
placeholder="Вариант ответа" maxlength="200">
</div>
<div class="input-group mb-2">
<span class="input-group-text">2</span>
<input type="text" class="form-control poll-option"
placeholder="Вариант ответа" maxlength="200">
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="addPollOption">
<i class="bi-plus-circle me-1"></i>Добавить вариант
</button>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="pollAnonymous">
<label class="form-check-label" for="pollAnonymous">
Анонимное голосование
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="pollMultiple">
<label class="form-check-label" for="pollMultiple">
Множественный выбор
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="pollQuiz">
<label class="form-check-label" for="pollQuiz">
Викторина (с правильным ответом)
</label>
</div>
</div>
<div class="mb-3" id="quizCorrectAnswer" style="display: none;">
<label class="form-label">Правильный ответ</label>
<select class="form-select" id="pollCorrectOption">
<option value="">Выберите правильный вариант</option>
</select>
</div>
<div class="mb-3">
<label for="pollCloses" class="form-label">
Автоматическое закрытие (минуты, необязательно)
</label>
<input type="number" class="form-control" id="pollCloses"
min="1" placeholder="Оставьте пустым для бессрочного">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Отмена
</button>
<button type="button" class="btn btn-primary" id="submitPoll">
Создать
</button>
</div>
</div>
</div>
</div>
</div> <!-- .chat-page -->
{% endblock %}
{% block extra_js %}
{{ block.super }}
{# ========== THIRD-PARTY ========== #}
<script type="module" src="{% static 'vendor/emoji-picker/index.js' %}"></script>
{# ========== CHAT DETAIL PAGE - CORE FUNCTIONALITY ========== #}
{# chatDetail.js - V2 архитектура с прямым импортом ChatControllerV2 #}
<script type="module" src="{% static 'js/pages/chatDetail.js' %}"></script>
{# ========== CHAT DETAIL - UI ENHANCEMENTS ========== #}
{# Реакции, контекстное меню, выделение, голосования #}
<script type="module" src="{% static 'js/chat-detail-enhanced.js' %}"></script>
<script>
// Блокировка объявления
async function blockAnnouncement(chatId) {
if (!confirm('Вы уверены, что хотите заблокировать это объявление? Оно исчезнет из списка чатов всех пользователей.')) {
return;
}
try {
const response = await fetch(`/communications/api/announcement/${chatId}/block/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
});
const data = await response.json();
if (response.ok) {
location.reload();
} else {
alert(data.error || 'Ошибка блокировки');
}
} catch (error) {
console.error('Error blocking announcement:', error);
alert('Ошибка при блокировке объявления');
}
}
// Разблокировка объявления
async function unblockAnnouncement(chatId) {
try {
const response = await fetch(`/communications/api/announcement/${chatId}/unblock/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
});
const data = await response.json();
if (response.ok) {
location.reload();
} else {
alert(data.error || 'Ошибка разблокировки');
}
} catch (error) {
console.error('Error unblocking announcement:', error);
alert('Ошибка при разблокировке объявления');
}
}
</script>
{% endblock %}