from __future__ import annotations import datetime from datetime import datetime as dt from datetime import timezone as dt_tz from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.db import IntegrityError, models 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, ChatReadState, Message # ===== 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 class ChatListView(LoginRequiredMixin, ListView): model = Chat template_name = "communications/chat_list.html" context_object_name = "chats" 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) qs = ( Chat.objects.filter( Q(type="global") | Q(type="department", department__in=departments) | Q(type="private", participants=user) ) .distinct() .select_related("department") .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 get_success_url(self): return reverse("communications:chat_detail", kwargs={"pk": self.object.pk}) def _user_has_access(self, chat: Chat, user) -> bool: if chat.type == "global": return True if chat.type == "private": return chat.participants.filter(pk=user.pk).exists() if chat.type == "department" and chat.department_id: return chat.get_participants.filter(pk=user.pk).exists() return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) chat: Chat = self.object user = self.request.user # защита доступа if not self._user_has_access(chat, user): from django.http import Http404 raise Http404("Chat not found") # сообщения + участники context["messages"] = chat.messages.select_related("author").order_by( "created_at" ) 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 return context def post(self, request, *args, **kwargs): self.object = self.get_object() user = request.user if not ChatDetailView._user_has_access(self, self.object, user): from django.http import HttpResponseForbidden return HttpResponseForbidden("forbidden") form = self.get_form() if form.is_valid(): msg = form.save(commit=False) msg.chat = self.object msg.author = user msg.save() # автору сразу «прочитано» — делаем безопасно ts = msg.created_at updated = ChatReadState.objects.filter( chat=self.object, user=user, last_read_at__lt=ts ).update(last_read_at=ts) if not updated: try: ChatReadState.objects.create( chat=self.object, user=user, last_read_at=ts ) except IntegrityError: ChatReadState.objects.filter( chat=self.object, 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 == "private": 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() 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)})