Основные возможности:
- Только 1 объявление на пользователя (database constraint)
- Автогенерация названия 'Объявления от {имя}'
- Только создатель может публиковать сообщения
- Админы могут блокировать/разблокировать объявления
- Заблокированные объявления скрыты от всех (кроме staff)
- Запрет на ответы (reply) в объявлениях (can_reply=False)
- Запрет на редактирование/удаление сообщений
- Уведомления всем участникам при новом объявлении
Детали реализации:
- Добавлены поля: is_blocked, blocked_at, blocked_by, can_reply
- API endpoints: /api/announcement/<id>/block/ и /unblock/
- Валидация на уровне views, API и WebSocket
- Новый тип уведомлений: announcement_new_message
- UI индикаторы блокировки и прав доступа
- Компактное отображение статуса 'только просмотр'
Затронуты файлы:
- models.py: новые поля и constraint
- api_views.py: валидация и API блокировки
- consumers.py: проверки прав в WebSocket
- views.py: фильтрация и контекст прав
- templates: UI блокировки и статусов
- notification_signals.py: уведомления для объявлений
508 lines
19 KiB
Python
508 lines
19 KiB
Python
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_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 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)}
|
||
)
|