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" and chat.department_id: 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 или membership if chat.include_all_employees: 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_qs = ChatReadState.objects.filter( chat=models.OuterRef("pk"), user=user ).values("last_read_at")[:1] default_dt = timezone.make_aware( datetime.datetime(1970, 1, 1), timezone.get_current_timezone() ).astimezone(dt_tz.utc) # Получаем ID чатов, где пользователь является участником # через membership from communications.models import ChatMembership membership_chat_ids = ChatMembership.objects.filter( user=user ).values_list('chat_id', flat=True) qs = ( Chat.objects.filter( Q(type="global") | Q(type="department", department__in=departments) | 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_at=Coalesce( Subquery(last_read_qs), Value(default_dt, output_field=models.DateTimeField()), output_field=models.DateTimeField(), ) ) .annotate( # считаем только чужие сообщения после last_read_at unread_count=Count( "messages", filter=Q(messages__created_at__gt=F("last_read_at")) & ~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 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}" ) qs = Chat.objects.filter( Q(type="global") | Q(type="department", department__in=departments) | 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_at и «первое непрочитанное» read_state = ( ChatReadState.objects.filter(chat=chat, user=user) .only("last_read_at") .first() ) last_read_at = ( read_state.last_read_at if read_state and read_state.last_read_at else None ) context["last_read_at"] = last_read_at first_unread = None if last_read_at: first_unread = ( chat.messages.exclude(author=user) .filter(created_at__gt=last_read_at) .order_by("created_at") .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 ) elif last_read_at: context["unread_from_ts"] = ( int(last_read_at.timestamp() * 1000) + 1 ) 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() # автору сразу «прочитано» — делаем безопасно ts = msg.created_at 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 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" and c.department_id: return c.get_participants.filter(pk=u.pk).exists() if c.type in ("channel", "announcement"): # Для каналов и объявлений проверяем participants или include_all if c.include_all_employees: 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_id, затем upto_ts, # затем последний месседж ts = None upto_id = request.POST.get("upto_id") if upto_id: m = ( Message.objects.filter(chat=chat, pk=upto_id) .only("created_at") .first() ) ts = m.created_at if m else None if ts is None: 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)} )