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:
parent
d491cbdff9
commit
45b35b0f36
@ -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
|
||||
|
||||
@ -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",
|
||||
}
|
||||
),
|
||||
}
|
||||
107
backend/communications/utils.py
Normal file
107
backend/communications/utils.py
Normal 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
|
||||
@ -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)}
|
||||
)
|
||||
@ -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>
|
||||
@ -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 %}
|
||||
@ -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 %}
|
||||
Loading…
x
Reference in New Issue
Block a user