refactor(communications): Удалить классические Django views и templates

- Удалены классические Django CBV views (ChatListView, ChatDetailView, ~400 строк)
- Удален MessageForm для HTML форм
- Удалены 6 HTML templates (chat_list.html, chat_detail.html, messengers/*)
- Утилиты _coerce_ts и user_can_access_chat перенесены в utils.py
- Обновлены импорты в communications/api/viewsets.py

Причина: Проект использует Next.js frontend + REST API + WebSocket.
Классические server-side rendered views больше не используются.

Результат:
- System check: 0 errors
- Код стал чище и проще для поддержки
- Нет зависимости от Django templates
- API и WebSocket остались полностью функциональны
This commit is contained in:
lizerk 2026-03-11 15:49:23 +03:00
parent d491cbdff9
commit 45b35b0f36
10 changed files with 108 additions and 1705 deletions

View File

@ -24,7 +24,7 @@ from communications.models import (Chat, ChatMembership, ChatReadState,
MessageAttachment, MessageReaction, Poll,
PollOption, PollVote)
from communications.serialization import serialize_message
from communications.views import _coerce_ts, user_can_access_chat
from communications.utils import _coerce_ts, user_can_access_chat
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Count, Exists, OuterRef, Prefetch, Q, Subquery

View File

@ -1,20 +0,0 @@
# backend/communications/forms.py
from django import forms
from .models import Message
class MessageForm(forms.ModelForm):
class Meta:
model = Message
fields = ["content"]
widgets = {
"content": forms.Textarea(
attrs={
"rows": 2,
"placeholder": "Введите сообщение...",
"class": "form-control",
}
),
}

View File

@ -0,0 +1,107 @@
"""
Утилиты для модуля communications.
Переиспользуемые функции для API, WebSocket и других компонентов.
"""
from __future__ import annotations
import datetime
from datetime import datetime as dt
from datetime import timezone as dt_tz
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from .models import Chat, ChatMembership
def _coerce_ts(val: str | None) -> datetime.datetime:
"""
Принимает миллисекунды/секунды с эпохи или ISO-дату.
Возвращает aware-дату (UTC). Фоллбек — timezone.now().
Args:
val: Строка с timestamp (ms/sec) или ISO-дата
Returns:
datetime: Aware datetime объект в UTC
Examples:
>>> _coerce_ts("1640000000000") # миллисекунды
datetime.datetime(2021, 12, 20, 13, 33, 20, tzinfo=UTC)
>>> _coerce_ts("1640000000") # секунды
datetime.datetime(2021, 12, 20, 13, 33, 20, tzinfo=UTC)
>>> _coerce_ts("2021-12-20T13:33:20Z") # ISO
datetime.datetime(2021, 12, 20, 13, 33, 20, tzinfo=UTC)
"""
if not val:
return timezone.now()
# Сначала пробуем число (sec/ms)
try:
iv = int(val)
if iv > 10**12: # пришли миллисекунды
iv //= 1000
return dt.fromtimestamp(iv, tz=dt_tz.utc)
except Exception:
pass
# Потом пробуем ISO-строку
d = parse_datetime(val)
if d is None:
return timezone.now()
if timezone.is_naive(d):
return timezone.make_aware(d, timezone=timezone.get_current_timezone())
return d
def user_can_access_chat(chat: Chat, user) -> bool:
"""
Проверяет, имеет ли пользователь доступ к чату.
Правила доступа по типам чатов:
- global: доступ всем
- private: только участники (participants)
- group: участники (participants) или ChatMembership
- department: через get_participants() (поддерживает department и context_object)
- channel/announcement: include_all_users или participants/membership
Args:
chat: Объект Chat
user: Объект User (Employee)
Returns:
bool: True если пользователь имеет доступ
"""
if not chat or not user:
return False
if chat.type == "global":
return True
if chat.type == "private":
# Личные чаты только через participants
return chat.participants.filter(pk=user.pk).exists()
if chat.type == "group":
# Групповые чаты через participants ИЛИ ChatMembership
in_participants = chat.participants.filter(pk=user.pk).exists()
in_membership = ChatMembership.objects.filter(chat=chat, user=user).exists()
return in_participants or in_membership
if chat.type == "department":
# Проверяем через get_participants() (поддерживает и department, и context_object)
return chat.get_participants().filter(pk=user.pk).exists()
if chat.type in ("channel", "announcement"):
# Для каналов и объявлений может быть include_all_users или membership
if chat.include_all_users:
return user.is_active
# Проверяем и participants и membership для гибкости
return (
chat.participants.filter(pk=user.pk).exists()
or ChatMembership.objects.filter(chat=chat, user=user).exists()
)
return False

View File

@ -1,526 +0,0 @@
from __future__ import annotations
import datetime
import logging
from datetime import datetime as dt
from datetime import timezone as dt_tz
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import IntegrityError, models
logger = logging.getLogger(__name__)
from django.db.models import Count, F, Prefetch, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, FormView, ListView
from .forms import MessageForm
from .models import Chat, ChatMembership, ChatReadState, Message
CHAT_MESSAGES_PAGE_SIZE = getattr(settings, "CHAT_MESSAGES_PAGE_SIZE", 50)
# ===== helpers =====
def _coerce_ts(val: str | None) -> datetime.datetime:
"""
Принимает миллисекунды/секунды с эпохи или ISO-дату.
Возвращает aware-дату (UTC). Фоллбек — timezone.now().
"""
if not val:
return timezone.now()
# сначала — число (sec/ms)
try:
iv = int(val)
if iv > 10**12: # пришли миллисекунды
iv //= 1000
return dt.fromtimestamp(iv, tz=dt_tz.utc)
except Exception:
pass
# потом — ISO-строка
d = parse_datetime(val)
if d is None:
return timezone.now()
if timezone.is_naive(d):
return timezone.make_aware(d, timezone=timezone.get_current_timezone())
return d
def user_can_access_chat(chat: Chat, user) -> bool:
if not chat or not user:
return False
print(f"[CHAT DEBUG] user_can_access_chat: chat={chat.id}, type={chat.type}, user={user.id}")
if chat.type == "global":
return True
if chat.type == "private":
# Личные чаты только через participants
result = chat.participants.filter(pk=user.pk).exists()
print(f"[CHAT DEBUG] Private chat, participants check: {result}")
return result
if chat.type == "group":
# Групповые чаты через participants ИЛИ ChatMembership
in_participants = chat.participants.filter(pk=user.pk).exists()
in_membership = ChatMembership.objects.filter(chat=chat, user=user).exists()
result = in_participants or in_membership
print(f"[CHAT DEBUG] Group chat, participants={in_participants}, membership={in_membership}, result={result}")
return result
if chat.type == "department":
# Проверяем через get_participants() (поддерживает и department, и context_object)
result = chat.get_participants().filter(pk=user.pk).exists()
print(f"[CHAT DEBUG] Department chat, result={result}")
return result
if chat.type in ("channel", "announcement"):
# Для каналов и объявлений может быть include_all_users или membership
if chat.include_all_users:
return user.is_active
# Проверяем и participants и membership для гибкости
result = (
chat.participants.filter(pk=user.pk).exists()
or ChatMembership.objects.filter(chat=chat, user=user).exists()
)
print(f"[CHAT DEBUG] Channel/announcement chat, result={result}")
return result
return False
class ChatListView(LoginRequiredMixin, ListView):
model = Chat
template_name = "communications/chat_list.html"
context_object_name = "chats"
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
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем список отделов для выбора при создании чата
from employees.models import Department
context['departments'] = Department.objects.all().order_by('name')
return context
def get_queryset(self):
user = self.request.user
# ВАЖНО: у Employee.departments это property, а не менеджер
departments = user.departments # уже queryset
last_qs = (
Message.objects.filter(chat=models.OuterRef("pk"))
.values("created_at")
.order_by("-created_at")[:1]
)
last_read_msg_qs = ChatReadState.objects.filter(
chat=models.OuterRef("pk"), user=user
).values("last_read_message_id")[:1]
# Получаем ID чатов, где пользователь является участником
# через membership
from communications.models import ChatMembership
from django.contrib.contenttypes.models import ContentType
membership_chat_ids = ChatMembership.objects.filter(
user=user
).values_list('chat_id', flat=True)
# Для department: проверяем и старое поле department, и новое context_object
dept_ct = ContentType.objects.get_for_model(departments.model)
dept_ids = list(departments.values_list('id', flat=True))
qs = (
Chat.objects.filter(
Q(type="global")
| Q(type="department", department__in=departments) # старое поле
| Q(type="department", context_content_type=dept_ct, context_object_id__in=dept_ids) # новое поле
| Q(type="private", participants=user)
| Q(id__in=membership_chat_ids) # group/channel/announcement
)
.exclude(
# Скрываем заблокированные announcement
Q(type="announcement", is_blocked=True) &
~Q(created_by=user) # Создателю показываем
)
.exclude(
# Скрываем заблокированные announcement и для создателя
# если он не staff (чтобы staff мог разблокировать)
Q(type="announcement", is_blocked=True, created_by=user) &
Q(created_by__is_staff=False)
)
.distinct()
.select_related("department", "created_by", "blocked_by")
.annotate(last_msg_at=Subquery(last_qs))
.annotate(
last_read_msg_id=Coalesce(
Subquery(last_read_msg_qs),
Value(0, output_field=models.IntegerField()),
output_field=models.IntegerField(),
)
)
.annotate(
# Считаем только чужие сообщения с ID больше last_read_message_id
unread_count=Count(
"messages",
filter=Q(messages__id__gt=F("last_read_msg_id"))
& ~Q(messages__author=user),
distinct=True,
)
)
.prefetch_related(
"participants",
Prefetch(
"messages",
queryset=Message.objects.select_related("author").order_by(
"-created_at"
)[:1],
to_attr="last_message",
),
)
.order_by(
models.F("last_msg_at").desc(nulls_last=True),
"-created_at",
)
)
return qs
class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
model = Chat
template_name = "communications/chat_detail.html"
context_object_name = "chat"
form_class = MessageForm
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
def get_queryset(self):
"""Возвращает только чаты, к которым пользователь имеет доступ"""
user = self.request.user
departments = user.departments
print(f"=" * 80)
print(f"[CHAT DEBUG] ChatDetailView.get_queryset called")
print(f"[CHAT DEBUG] User: {user.id} ({user.username or 'NO USERNAME'})")
print(f"[CHAT DEBUG] Requested chat PK: {self.kwargs.get('pk')}")
logger.info(
f"ChatDetailView.get_queryset: user={user.id} ({user.username}), "
f"pk={self.kwargs.get('pk')}"
)
# Получаем ID чатов через membership
from communications.models import ChatMembership
from django.contrib.contenttypes.models import ContentType
membership_chat_ids = list(
ChatMembership.objects.filter(user=user).values_list('chat_id', flat=True)
)
dept_ids = list(departments.values_list('id', flat=True))
print(f"[CHAT DEBUG] User departments IDs: {dept_ids}")
print(f"[CHAT DEBUG] User membership_chat_ids: {membership_chat_ids}")
logger.info(
f"User departments: {list(departments.values_list('id', flat=True))}, "
f"membership_chat_ids: {membership_chat_ids}"
)
# Для department: проверяем и старое поле, и новое
dept_ct = ContentType.objects.get_for_model(departments.model)
qs = Chat.objects.filter(
Q(type="global")
| Q(type="department", department__in=departments) # старое поле
| Q(type="department", context_content_type=dept_ct, context_object_id__in=dept_ids) # новое поле
| Q(type="private", participants=user)
| Q(id__in=membership_chat_ids)
).distinct()
available_ids = list(qs.values_list('id', flat=True))
print(f"[CHAT DEBUG] Available chats for user: {available_ids}")
print(f"[CHAT DEBUG] Access to requested chat: {self.kwargs.get('pk') in available_ids}")
print(f"=" * 80)
logger.info(f"Available chats for user: {list(qs.values_list('id', flat=True))}")
return qs
def get_object(self, queryset=None):
"""Получаем объект чата с дополнительным логированием"""
print(f"[CHAT DEBUG] get_object called")
try:
obj = super().get_object(queryset)
print(f"[CHAT DEBUG] Object found: Chat {obj.id}, type={obj.type}")
return obj
except Exception as e:
print(f"[CHAT DEBUG] Exception in get_object: {type(e).__name__}: {e}")
raise
def get_success_url(self):
return reverse(
"communications:chat_detail", kwargs={"pk": self.object.pk}
)
def _user_has_access(self, chat: Chat, user) -> bool:
return user_can_access_chat(chat, user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
chat: Chat = self.object
user = self.request.user
print(f"[CHAT DEBUG] get_context_data called for chat {chat.id}")
# защита доступа
has_access = self._user_has_access(chat, user)
print(f"[CHAT DEBUG] _user_has_access returned: {has_access}")
if not has_access:
from django.http import Http404
print(f"[CHAT DEBUG] Raising Http404 because user has no access")
raise Http404("Chat not found")
# сообщения + участники
messages_qs = (
chat.messages
.select_related("author")
.prefetch_related("attachments")
.order_by("-created_at")
)
page_size = CHAT_MESSAGES_PAGE_SIZE
raw_messages = list(messages_qs[: page_size + 1])
has_more = len(raw_messages) > page_size
next_cursor = None
if has_more:
next_cursor = raw_messages.pop()
displayed_messages = list(reversed(raw_messages))
oldest_displayed = raw_messages[-1] if raw_messages else next_cursor
context["messages"] = displayed_messages
context["messages_has_more"] = has_more
context["messages_oldest_id"] = (
oldest_displayed.id if oldest_displayed else None
)
context["messages_oldest_ts"] = (
int(oldest_displayed.created_at.timestamp() * 1000)
if oldest_displayed
else None
)
context["messages_page_size"] = page_size
context["form"] = context.get("form", MessageForm())
context["participants"] = chat.get_participants
# Получаем last_read_message_id для определения непрочитанных (Telegram-style)
read_state = (
ChatReadState.objects.filter(chat=chat, user=user)
.only("last_read_message_id")
.first()
)
last_read_message_id = (
read_state.last_read_message_id
if read_state and read_state.last_read_message_id
else None
)
context["last_read_message_id"] = last_read_message_id
# Передаем last_read_message_id в chat объект для data-атрибута
chat.last_read_message_id = last_read_message_id
first_unread = None
if last_read_message_id:
# Первое непрочитанное = сообщение с ID > last_read_message_id
first_unread = (
chat.messages.exclude(author=user)
.filter(id__gt=last_read_message_id)
.order_by("id")
.only("id", "created_at")
.first()
)
context["first_unread_id"] = first_unread.id if first_unread else None
if first_unread:
context["unread_from_ts"] = int(
first_unread.created_at.timestamp() * 1000
)
else:
context["unread_from_ts"] = None
# Права на отправку сообщений в объявлениях
if chat.type == "announcement":
# Только создатель может писать в объявление
context["can_send_messages"] = (chat.created_by == user)
context["is_announcement_creator"] = (chat.created_by == user)
else:
# В других типах чатов проверяем can_send_messages
membership = ChatMembership.objects.filter(
chat=chat, user=user
).first()
context["can_send_messages"] = (
membership.can_send_messages if membership
else True # По умолчанию можно писать
)
context["is_announcement_creator"] = False
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
user = request.user
chat = self.object
if not ChatDetailView._user_has_access(self, chat, user):
from django.http import HttpResponseForbidden
return HttpResponseForbidden("forbidden")
# Проверка прав на отправку сообщений
if chat.type == "announcement":
# Только создатель может писать в объявление
if chat.created_by != user:
from django.http import HttpResponseForbidden
return HttpResponseForbidden(
"Только автор может публиковать в это объявление"
)
else:
# В других типах чатов проверяем can_send_messages
membership = ChatMembership.objects.filter(
chat=chat, user=user
).first()
if membership and not membership.can_send_messages:
from django.http import HttpResponseForbidden
return HttpResponseForbidden(
"У вас нет прав для отправки сообщений"
)
form = self.get_form()
if form.is_valid():
msg = form.save(commit=False)
msg.chat = chat
msg.author = user
msg.save()
# Автоматически отмечаем свое сообщение как прочитанное (Telegram-style)
read_state, created = ChatReadState.objects.get_or_create(
chat=chat,
user=user,
defaults={'last_read_message': msg}
)
if not created:
# Обновляем только если новое сообщение НОВЕЕ
if not read_state.last_read_message_id or msg.id > read_state.last_read_message_id:
read_state.last_read_message = msg
read_state.save(update_fields=['last_read_message', 'updated_at'])
return redirect(self.get_success_url())
return self.render_to_response(self.get_context_data(form=form))
def start_private_chat(request, employee_pk):
from employees.models import Employee
target = get_object_or_404(Employee, pk=employee_pk)
user = request.user
chat = (
Chat.objects.filter(type="private", participants=user)
.filter(participants=target)
.first()
)
if not chat:
chat = Chat.objects.create(type="private")
chat.participants.add(user, target)
# новый чат помечаем прочитанным для создателя (нет непрочитанных)
now_ts = timezone.now()
try:
ChatReadState.objects.create(
chat=chat,
user=user,
last_read_at=now_ts,
)
except IntegrityError:
ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=now_ts
).update(last_read_at=now_ts)
return redirect("communications:chat_detail", pk=chat.pk)
@login_required
@require_POST
def chat_mark_read(request, pk: int):
chat = get_object_or_404(Chat, pk=pk)
user = request.user
# доступ
def has_access(c: Chat, u) -> bool:
if c.type == "global":
return True
if c.type in ("private", "group"): # Групповые и личные чаты
return c.participants.filter(pk=u.pk).exists()
if c.type == "department":
# Используем get_participants() - поддерживает и department, и context_object
return c.get_participants().filter(pk=u.pk).exists()
if c.type in ("channel", "announcement"):
# Для каналов и объявлений проверяем participants или include_all_users
if c.include_all_users:
return u.is_active
return c.participants.filter(pk=u.pk).exists()
return False
if not has_access(chat, user):
return JsonResponse({"ok": False, "error": "forbidden"}, status=403)
# Целевая точка времени: приоритет у upto_ts, затем последний месседж
ts = _coerce_ts(request.POST.get("upto_ts"))
if ts is None:
# Фоллбек на последнее сообщение
ts = (
chat.messages.order_by("-created_at")
.values_list("created_at", flat=True)
.first()
or timezone.now()
)
# Обновляем только если новее; без update_or_create — устойчиво к SQLite/гонкам
updated = ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
if not updated:
try:
ChatReadState.objects.create(chat=chat, user=user, last_read_at=ts)
except IntegrityError:
ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
return JsonResponse(
{"ok": True, "last_read_at": int(ts.timestamp() * 1000)}
)

View File

@ -1,23 +0,0 @@
{# Фильтры для страницы чатов #}
<form method="get" class="row g-2 align-items-end">
<div class="col-12 col-sm-8">
<label class="form-label mb-1">Тип чата</label>
<select name="chat_type" class="form-select" id="chatTypeFilter">
<option value="all" selected>Все</option>
<option value="global">Глобальный</option>
<option value="department">Отделы</option>
<option value="private">Личные</option>
<option value="group">Группы</option>
<option value="channel">Каналы</option>
<option value="announcement">Объявления</option>
</select>
</div>
<div class="col-12 col-sm-4 d-flex justify-content-end gap-2">
<button class="btn btn-outline-primary d-flex align-items-center justify-content-center gap-1" type="button" id="applyChatFilter">
<i class="bi-funnel"></i><span>Применить</span>
</button>
<button type="button" class="btn btn-outline-secondary d-flex align-items-center justify-content-center" id="resetChatFilter" title="Сбросить фильтры">
<i class="bi-x-lg"></i>
</button>
</div>
</form>

View File

@ -1,441 +0,0 @@
{% 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 %}

View File

@ -1,694 +0,0 @@
{# backend/templates/communications/chat_list.html #}
{% extends "base.html" %}
{% load static django_bootstrap5 %}
{% block title %}Мои чаты{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/chat-list-enhanced.css' %}">
{% endblock %}
{% block content %}
<div class="container">
<div class="row gx-0">
<div class="col-12">
{# ===== НОВАЯ ШАПКА С КОМПОНЕНТОМ ===== #}
{% include "includes/components/page_header.html" with icon="bi-chat-dots" title="Мои чаты" search_id="chatSearch" search_placeholder="Поиск по названию или участникам" create_as_card=True create_modal="chatCreateModal" create_text="Создать чат" %}
{# ===== ДВУХКОЛОНОЧНАЯ СТРУКТУРА ===== #}
<div class="d-flex gap-3 chat-list-wrapper">
{# Левая колонка - иконки типов чатов #}
<div class="chat-type-sidebar">
<div class="card">
<div class="list-group list-group-flush">
<button type="button" class="list-group-item list-group-item-action text-center py-3 chat-type-btn active"
data-chat-type="all" title="Все чаты">
<i class="bi-chat-dots fs-4 d-block"></i>
<span class="badge badge--muted" data-count="all" style="display: none;">0</span>
</button>
<button type="button" class="list-group-item list-group-item-action text-center py-3 chat-type-btn"
data-chat-type="global" title="Глобальный">
<i class="bi-globe fs-4 d-block"></i>
<span class="badge badge--muted" data-count="global" style="display: none;">0</span>
</button>
<button type="button" class="list-group-item list-group-item-action text-center py-3 chat-type-btn"
data-chat-type="department" title="Отделы">
<i class="bi-diagram-3 fs-4 d-block"></i>
<span class="badge badge--muted" data-count="department" style="display: none;">0</span>
</button>
<button type="button" class="list-group-item list-group-item-action text-center py-3 chat-type-btn"
data-chat-type="private" title="Личные">
<i class="bi-person-fill fs-4 d-block"></i>
<span class="badge badge--muted" data-count="private" style="display: none;">0</span>
</button>
<button type="button" class="list-group-item list-group-item-action text-center py-3 chat-type-btn"
data-chat-type="group" title="Группы">
<i class="bi-people-fill fs-4 d-block"></i>
<span class="badge badge--muted" data-count="group" style="display: none;">0</span>
</button>
<button type="button" class="list-group-item list-group-item-action text-center py-3 chat-type-btn"
data-chat-type="channel" title="Каналы">
<i class="bi-broadcast fs-4 d-block"></i>
<span class="badge badge--muted" data-count="channel" style="display: none;">0</span>
</button>
<button type="button" class="list-group-item list-group-item-action text-center py-3 chat-type-btn"
data-chat-type="announcement" title="Объявления">
<i class="bi-megaphone fs-4 d-block"></i>
<span class="badge badge--muted" data-count="announcement" style="display: none;">0</span>
</button>
</div>
</div>
</div>
{# Правая колонка - список чатов #}
<div class="chat-list-main flex-grow-1">
{# ===== 1) ГЛОБАЛЬНЫЙ ===== #}
<div class="card chat-section mb-3" data-sec="global">
<div class="card-header">
<div class="card-icon" style="width:44px;height:44px;"><i class="bi-globe"></i></div>
<div class="card-meta flex-grow-1">
<h6 class="mb-0 text-secondary">Глобальный</h6>
<span class="small text-secondary" data-empty-note style="display:none;">Нет глобального чата</span>
</div>
</div>
<div id="sec-global" class="list-chats">
{% for chat in chats %}
{% if chat.type == 'global' %}
{% include "includes/components/chat_card.html" with chat=chat %}
{% endif %}
{% endfor %}
</div>
</div>
{# ===== 2) ОТДЕЛЫ ===== #}
<div class="card chat-section mb-3" data-sec="department">
<div class="card-header">
<div class="card-icon" style="width:44px;height:44px;"><i class="bi-diagram-3"></i></div>
<div class="card-meta flex-grow-1">
<h6 class="mb-0 text-secondary">Отделы</h6>
<span class="small text-secondary" data-empty-note style="display:none;">Нет чатов отделов</span>
</div>
</div>
<div id="sec-department" class="list-chats">
{% for chat in chats %}
{% if chat.type == 'department' %}
{% include "includes/components/chat_card.html" with chat=chat %}
{% endif %}
{% endfor %}
</div>
</div>
{# ===== 3) ЛИЧНЫЕ ===== #}
<div class="card chat-section mb-3" data-sec="private">
<div class="card-header">
<div class="card-icon" style="width:44px;height:44px;"><i class="bi-people"></i></div>
<div class="flex-grow-1 card-meta">
<h6 class="mb-0 text-secondary">Личные</h6>
<span class="small text-secondary" data-empty-note style="display:none;">Нет личных чатов</span>
</div>
</div>
<div id="sec-private" class="list-chats">
{% for chat in chats %}
{% if chat.type == 'private' %}
{% include "includes/components/chat_card.html" with chat=chat %}
{% endif %}
{% endfor %}
</div>
</div>
{# ===== 4) ГРУППЫ ===== #}
<div class="card chat-section mb-3" data-sec="group">
<div class="card-header">
<div class="card-icon" style="width:44px;height:44px;"><i class="bi-people-fill"></i></div>
<div class="flex-grow-1 card-meta">
<h6 class="mb-0 text-secondary">Группы</h6>
<span class="small text-secondary" data-empty-note style="display:none;">Нет групповых чатов</span>
</div>
</div>
<div id="sec-group" class="list-chats">
{% for chat in chats %}
{% if chat.type == 'group' %}
{% include "includes/components/chat_card.html" with chat=chat %}
{% endif %}
{% endfor %}
</div>
</div>
{# ===== 5) КАНАЛЫ ===== #}
<div class="card chat-section mb-3" data-sec="channel">
<div class="card-header">
<div class="card-icon" style="width:44px;height:44px;"><i class="bi-broadcast"></i></div>
<div class="flex-grow-1 card-meta">
<h6 class="mb-0 text-secondary">Каналы</h6>
<span class="small text-secondary" data-empty-note style="display:none;">Нет каналов</span>
</div>
</div>
<div id="sec-channel" class="list-chats">
{% for chat in chats %}
{% if chat.type == 'channel' %}
{% include "includes/components/chat_card.html" with chat=chat %}
{% endif %}
{% endfor %}
</div>
</div>
{# ===== 6) ОБЪЯВЛЕНИЯ ===== #}
<div class="card chat-section mb-3" data-sec="announcement">
<div class="card-header">
<div class="card-icon" style="width:44px;height:44px;"><i class="bi-megaphone"></i></div>
<div class="flex-grow-1 card-meta">
<h6 class="mb-0 text-secondary">Объявления</h6>
<span class="small text-secondary" data-empty-note style="display:none;">Нет объявлений</span>
</div>
</div>
<div id="sec-announcement" class="list-chats">
{% for chat in chats %}
{% if chat.type == 'announcement' %}
{% include "includes/components/chat_card.html" with chat=chat %}
{% endif %}
{% endfor %}
</div>
</div>
{% if not chats %}
<div class="alert alert-info shadow-sm mt-3">
Чатов пока нет. Начните с <a href="{% url 'employees:employees_list' %}" class="text-decoration-none">поиска сотрудника</a>.
</div>
{% endif %}
</div> {# конец chat-list-main #}
</div> {# конец d-flex #}
</div>
</div>
</div>
{# ===== МОДАЛКА СОЗДАНИЯ ЧАТА ===== #}
<div class="modal fade" id="chatCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Создать чат</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
{# Выбор типа чата #}
<div class="mb-3">
<label for="chatTypeSelect" class="form-label">Тип чата <span class="text-danger">*</span></label>
<select class="form-select" id="chatTypeSelect">
<option value="private">Личный чат</option>
<option value="group">Группа</option>
<option value="department">Чат отдела</option>
<option value="channel">Канал</option>
<option value="announcement">Объявления</option>
<option value="global">Глобальный чат</option>
</select>
<div class="form-text">
<small>
<strong>Личный:</strong> приватный чат с 1 сотрудником.<br>
<strong>Группа:</strong> чат с несколькими участниками.<br>
<strong>Отдел:</strong> автоматически включает всех сотрудников отдела.<br>
<strong>Канал:</strong> для объявлений с ограниченной отправкой.<br>
<strong>Объявления:</strong> односторонняя рассылка.<br>
<strong>Глобальный:</strong> общий чат компании (может быть только 1 основной).
</small>
</div>
</div>
{# Поля для группы/канала/объявлений/глобального #}
<div id="groupFieldsSection" style="display:none;">
<div class="mb-3">
<label for="chatNameInput" class="form-label">Название чата <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="chatNameInput" placeholder="Введите название">
</div>
<div class="mb-3">
<label for="chatDescInput" class="form-label">Описание</label>
<textarea class="form-control" id="chatDescInput" rows="3" placeholder="Краткое описание чата"></textarea>
</div>
{# Аватар чата #}
<div class="mb-3">
<label for="chatAvatarInput" class="form-label">Аватар чата</label>
<div class="d-flex align-items-center gap-3">
<div id="chatAvatarPreview" class="position-relative bg-light rounded-circle d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
<i class="bi bi-people-fill text-secondary" style="font-size: 2rem;"></i>
</div>
<div class="flex-grow-1">
<input type="file" class="form-control" id="chatAvatarInput" accept="image/jpeg,image/png,image/gif">
<div class="form-text">JPG, PNG или GIF. Макс. 5 МБ. Будет сжато до 512x512px.</div>
</div>
</div>
</div>
</div>
{# Выбор отдела (только для типа "department") #}
<div id="departmentSection" style="display:none;">
<div class="mb-3">
<label for="chatDepartmentSelect" class="form-label">Отдел <span class="text-danger">*</span></label>
<select class="form-select" id="chatDepartmentSelect">
<option value="">-- Выберите отдел --</option>
{% for dept in departments %}
<option value="{{ dept.id }}">{{ dept.name }}</option>
{% endfor %}
</select>
<div class="form-text">Все сотрудники выбранного отдела автоматически станут участниками чата.</div>
</div>
</div>
{# Опция "Включить всех сотрудников" (для announcement/global/channel) #}
<div id="includeAllSection" style="display:none;">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="includeAllEmployees">
<label class="form-check-label" for="includeAllEmployees">
Включить всех сотрудников компании
</label>
<div class="form-text">Все активные сотрудники автоматически станут участниками чата.</div>
</div>
</div>
{# Опция "Основной чат" (для global/department) #}
<div id="mainChatSection" style="display:none;">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="isMainChat">
<label class="form-check-label" for="isMainChat">
Сделать основным чатом
</label>
<div class="form-text">Основной чат отображается первым в списке. Может быть только 1 основной глобальный чат и 1 основной чат на отдел.</div>
</div>
</div>
{# Выбор участников (для личного чата и группы) #}
<div id="recipientSection">
<label class="form-label">Выберите участников <span id="recipientRequired" class="text-danger" style="display:none;">*</span></label>
<div id="chatRecipients" class="recipient-picker" data-api-employees="{% url 'api:v1:employees-list' %}">
<div class="rp-selected" aria-live="polite"></div>
<div class="rp-search mb-2">
<input type="search" class="form-control" placeholder="Найти сотрудника (имя, почта, телефон)">
</div>
<div class="rp-results" role="listbox" aria-label="Сотрудники"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="button" id="createChatBtn"><i class="bi-check-lg"></i> Создать</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
</div>
</div>
</div>
</div>
<script type="module">
import { initSearchFilter } from "{% static 'js/components/searchFilterHandler.js' %}";
import { RecipientPicker } from "{% static 'js/components/recipientPickerHandler.js' %}";
// Получаем токен доступа
const accessMeta = document.querySelector('meta[name="api-access"]');
const ACCESS = accessMeta ? accessMeta.content : '';
const headersAuth = ACCESS ? { 'Authorization': 'Bearer ' + ACCESS } : {};
// Динамически рассчитываем высоту page-header для смещения липкой панели
const chatListWrapper = document.querySelector('.chat-list-wrapper');
function updateSidebarOffset() {
const pageHeader = document.querySelector('.page-header');
if (!pageHeader) return;
const headerStyles = window.getComputedStyle(pageHeader);
const marginBottom = parseFloat(headerStyles.marginBottom) || 0;
const offset = pageHeader.offsetHeight + marginBottom;
document.documentElement.style.setProperty('--chat-sidebar-offset', `${offset}px`);
}
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateSidebarOffset, 150);
});
updateSidebarOffset();
// Инициализация RecipientPicker для создания чата
const chatPicker = new RecipientPicker(
document.getElementById('chatRecipients'),
{
headers: headersAuth,
apiUrl: "{% url 'api:v1:employees-list' %}",
maxSelections: 1 // По умолчанию для личного чата
}
);
// Переключение типа чата
const chatTypeSelect = document.getElementById('chatTypeSelect');
const recipientSection = document.getElementById('recipientSection');
const recipientRequired = document.getElementById('recipientRequired');
const groupFieldsSection = document.getElementById('groupFieldsSection');
const departmentSection = document.getElementById('departmentSection');
const includeAllSection = document.getElementById('includeAllSection');
const mainChatSection = document.getElementById('mainChatSection');
chatTypeSelect?.addEventListener('change', function() {
const type = this.value;
// Скрываем все секции по умолчанию
recipientSection.style.display = 'none';
recipientRequired.style.display = 'none';
groupFieldsSection.style.display = 'none';
departmentSection.style.display = 'none';
includeAllSection.style.display = 'none';
mainChatSection.style.display = 'none';
if(type === 'private') {
// Личный чат: только выбор 1 участника
recipientSection.style.display = 'block';
recipientRequired.style.display = 'inline';
chatPicker.setMaxSelections(1);
} else if(type === 'group') {
// Группа: название + описание + аватар + участники
recipientSection.style.display = 'block';
groupFieldsSection.style.display = 'block';
chatPicker.setMaxSelections(100);
} else if(type === 'department') {
// Чат отдела: название + описание + аватар + выбор отдела + опция "основной"
groupFieldsSection.style.display = 'block';
departmentSection.style.display = 'block';
mainChatSection.style.display = 'block';
} else if(type === 'global') {
// Глобальный: название + описание + аватар + опция "включить всех" + опция "основной"
groupFieldsSection.style.display = 'block';
includeAllSection.style.display = 'block';
mainChatSection.style.display = 'block';
} else {
// channel, announcement: название + описание + аватар + опция "включить всех"
groupFieldsSection.style.display = 'block';
includeAllSection.style.display = 'block';
}
});
// Предпросмотр аватара чата
const chatAvatarInput = document.getElementById('chatAvatarInput');
const chatAvatarPreview = document.getElementById('chatAvatarPreview');
chatAvatarInput?.addEventListener('change', function(e) {
const file = e.target.files[0];
if(!file) {
// Восстанавливаем иконку по умолчанию
chatAvatarPreview.innerHTML = '<i class="bi bi-people-fill text-secondary" style="font-size: 2rem;"></i>';
return;
}
// Валидация размера
if(file.size > 5 * 1024 * 1024) {
alert('Файл слишком большой. Максимум 5 МБ.');
this.value = '';
return;
}
// Валидация типа
if(!file.type.match(/^image\/(jpeg|png|gif)$/)) {
alert('Разрешены только JPG, PNG или GIF.');
this.value = '';
return;
}
// Показываем предпросмотр
const reader = new FileReader();
reader.onload = function(e) {
chatAvatarPreview.innerHTML = `<img src="${e.target.result}" alt="Preview" class="rounded-circle" style="width: 100%; height: 100%; object-fit: cover;">`;
};
reader.readAsDataURL(file);
});
// Функция получения CSRF токена
function getCSRFToken() {
// Сначала пробуем найти в скрытом поле формы
const tokenInput = document.querySelector('[name=csrfmiddlewaretoken]');
if (tokenInput) {
return tokenInput.value;
}
// Иначе берем из cookie
const name = 'csrftoken';
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// Обработка создания чата
document.getElementById('createChatBtn')?.addEventListener('click', async () => {
const type = chatTypeSelect.value;
const csrftoken = getCSRFToken();
if(type === 'private') {
// Создание личного чата
const selected = chatPicker.getSelected();
if (selected.length === 0) {
alert('Выберите сотрудника для чата');
return;
}
const employeeId = selected[0].id;
window.location.href = "{% url 'communications:start_private_chat' 0 %}".replace('/0/', `/${employeeId}/`);
} else {
// Создание group/department/channel/announcement/global через API
const nameInput = document.getElementById('chatNameInput');
const descInput = document.getElementById('chatDescInput');
const departmentSelect = document.getElementById('chatDepartmentSelect');
const includeAllCheckbox = document.getElementById('includeAllEmployees');
const isMainCheckbox = document.getElementById('isMainChat');
const avatarInput = document.getElementById('chatAvatarInput');
const name = nameInput?.value.trim() || '';
const description = descInput?.value.trim() || '';
// Валидация обязательных полей
if(!name && type !== 'private') {
alert('Введите название чата');
return;
}
if(type === 'department') {
const deptId = departmentSelect?.value;
if(!deptId) {
alert('Выберите отдел для чата отдела');
return;
}
}
// Собираем данные в FormData (для поддержки файлов)
const formData = new FormData();
formData.append('type', type);
formData.append('name', name);
formData.append('description', description);
// Добавляем аватар если выбран
if(avatarInput?.files[0]) {
formData.append('avatar', avatarInput.files[0]);
}
// Добавляем department_id для типа "department"
if(type === 'department' && departmentSelect?.value) {
formData.append('department_id', departmentSelect.value);
}
// Добавляем include_all_employees
if(includeAllCheckbox?.checked) {
formData.append('include_all_employees', 'true');
}
// Добавляем is_main
if(isMainCheckbox?.checked) {
formData.append('is_main', 'true');
}
// Собираем participant_ids для группового чата
if(type === 'group') {
const selected = chatPicker.getSelected();
selected.forEach(s => {
formData.append('participant_ids', s.id);
});
}
const res = await fetch('/api/v1/communications/chats/', {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken
// НЕ устанавливаем Content-Type, браузер сам добавит multipart/form-data с boundary
},
body: formData
});
if(res.ok) {
const data = await res.json();
window.location.href = `/communications/chats/${data.chat_id}/`;
} else {
const err = await res.json();
// Если это ошибка с existing_chat_id - редиректим на существующий чат
if(err.existing_chat_id) {
if(confirm(err.error + '\n\nПерейти к вашему чату объявлений?')) {
window.location.href = `/communications/chats/${err.existing_chat_id}/`;
}
} else {
alert('Ошибка: ' + (err.error || 'Неизвестная ошибка'));
}
}
}
});
// Инициализация поиска и фильтрации
const filterApi = initSearchFilter({
inputId: 'chatSearch',
clearSelector: '.ios-search-clear',
sectionSelector: '.chat-section',
rowSelector: '.chat-row'
});
// ===== ФИЛЬТРАЦИЯ ПО ТИПАМ ЧАТОВ =====
let activeFilter = 'all';
const chatTypeButtons = document.querySelectorAll('.chat-type-btn');
const chatSections = document.querySelectorAll('.chat-section');
// Функция подсчета чатов по типам и управления видимостью
function updateChatCounts() {
const counts = {
all: 0,
global: 0,
department: 0,
private: 0,
group: 0,
channel: 0,
announcement: 0
};
// Подсчитываем чаты в каждой секции
chatSections.forEach(section => {
const sectionType = section.dataset.sec;
const chatRows = section.querySelectorAll('.chat-row');
// Считаем только видимые (не скрытые поиском) чаты
const visibleChats = Array.from(chatRows).filter(row => {
return row.style.display !== 'none';
}).length;
counts[sectionType] = visibleChats;
counts.all += visibleChats;
// Управление видимостью секции
// 1. Если чатов нет (скрыты поиском или отсутствуют) -> скрываем секцию
if (visibleChats === 0) {
section.style.display = 'none';
}
// 2. Если чаты есть, проверяем активный фильтр
else {
if (activeFilter === 'all' || activeFilter === sectionType) {
section.style.display = '';
} else {
section.style.display = 'none';
}
}
});
// Обновляем бейджи и кнопки
Object.keys(counts).forEach(type => {
const badge = document.querySelector(`[data-count="${type}"]`);
const button = document.querySelector(`[data-chat-type="${type}"]`);
if (badge) {
badge.textContent = counts[type];
// Telegram-стиль: показываем/скрываем с анимацией
if (counts[type] === 0) {
badge.style.display = 'none';
badge.classList.remove('animate-in', 'pulse');
} else {
badge.style.display = '';
if (badge.textContent !== String(counts[type])) {
badge.classList.add('pulse');
setTimeout(() => badge.classList.remove('pulse'), 600);
}
}
}
// Скрываем кнопку категории, если в ней нет чатов (кроме 'all')
if (button && type !== 'all') {
button.style.display = counts[type] === 0 ? 'none' : '';
}
});
}
function applyTypeFilter(filterType) {
activeFilter = filterType;
// Обновляем активную кнопку
chatTypeButtons.forEach(btn => {
if (btn.dataset.chatType === filterType) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Переприменяем поиск (если есть), это обновит видимость строк
if (filterApi && filterApi.reapply) {
filterApi.reapply();
}
// Обновляем видимость секций и счетчики
updateChatCounts();
}
// Обработка кликов по кнопкам типов
chatTypeButtons.forEach(btn => {
btn.addEventListener('click', () => {
const filterType = btn.dataset.chatType;
applyTypeFilter(filterType);
});
});
// Применяем начальный фильтр и подсчитываем чаты
applyTypeFilter('all');
// Обновляем счетчики после поиска
const searchInput = document.getElementById('chatSearch');
if (searchInput) {
searchInput.addEventListener('input', () => {
setTimeout(updateChatCounts, 100);
});
}
</script>
{# ========== CHAT SCRIPTS ========== #}
{# Общие скрипты для всех chat-страниц #}
{% include "includes/chat_scripts.html" %}
{# Скрипты специфичные для chat_list #}
{% include "includes/chat_list_scripts.html" %}
{# УСТАРЕЛО: chat-list-enhanced.js больше не используется #}
{# Весь функционал перенесён в модульные компоненты: #}
{# - chatListFilter.js - фильтрация чатов #}
{# - chatListRealtime.js - real-time обновления #}
{# - userWebSocket.js - единое WS соединение #}
{% endblock %}