455 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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 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
)
.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_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
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 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)}
)