Corp/backend/communications/consumers.py
Игорь Надеин 52f2aed5fd From: You <you@example.com>
Date: Mon, 22 Sep 2025 10:30:00 +0300
Subject: [requests] Drawer «Рассмотреть», корректный чекбокс «Черновик», дружелюбные даты,
 robust-обработка ошибок reject, и глобальный бейдж непрочитанных чатов

- Список заявлений:
  - вместо россыпи кнопок добавлен аккуратный выезжающий блок действий (collapse drawer)
    по клику «Рассмотреть»; компактные нейтральные стили, мягкая анимация.
  - в модале «Изменить» добавлена галочка «Сохранить как черновик» с правильной
    семантикой: hidden `_save_as=submit` по умолчанию + checkbox `_save_as=draft`.
    Теперь снятие галочки действительно снимает draft.
  - улучшены стили и структура разметки.

- Даты:
  - на фронт-обёртке нормализуем datetime-поля (created_at/updated_at/decided_at)
    в человекочитаемый вид, чтобы не светить сырые ISO 8601.

- Обработка ошибок:
  - фикс падения при попытке отклонить уже одобренную заявку: ответ 400 у DRF
    может приходить списком ошибок; теперь сообщение извлекается безопасно.

- Чаты:
  - синхронизировали название ключа контекста с проектом (`chat_unread_total`);
  - бейдж «Чаты» обновляется глобальным событием `chats:unread-total`, даже если
    список чатов не открыт (fallback — суммирование счётчиков на странице).

Примечание: пути к шаблонам могут отличаться в вашем проекте — ниже даны
типовые варианты (`backend/requests_app/templates/...`). Если у вас другой
layout, внесите изменения в соответствующие файлы шаблонов.
2025-09-22 13:01:20 +03:00

159 lines
5.8 KiB
Python

# communications/consumers.py
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.contrib.auth.models import AnonymousUser
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from .models import Chat, ChatReadState, Message
def serialize_message(m: Message) -> dict:
author = m.author
author_name = author.get_full_name() or author.username
avatar = ""
try:
if getattr(author, "avatar", None) and author.avatar:
avatar = author.avatar.url
except Exception:
avatar = ""
return {
"id": m.id,
"content": m.content,
"author_id": author.id if author else None,
"author_name": author_name,
"author_url": reverse("employees:employee_detail", args=[author.id]) if author else "",
"avatar": avatar,
"created": m.created_at.strftime("%d.%m.%Y %H:%M"),
"created_ts": int(m.created_at.timestamp() * 1000), # ⬅ важно для сортировки на клиенте
}
class ChatConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
user = self.scope.get("user")
if not user or isinstance(user, AnonymousUser) or not user.is_authenticated:
await self.close(code=4401); return
self.chat_id = int(self.scope["url_route"]["kwargs"]["chat_id"])
self.group_name = f"chat_{self.chat_id}"
chat = await self._get_chat(self.chat_id)
if not await self._user_can_access(chat, user):
await self.close(code=4403); return
self.chat = chat
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
# помечаем как прочитанное «до текущего»
await self._mark_read(self.chat, user)
async def disconnect(self, code):
if hasattr(self, "group_name"):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive_json(self, content, **kwargs):
# ждём {"content": "..."} или {"action": "...", "text": "..."}
text = ""
if isinstance(content, dict):
text = str(content.get("content") or content.get("text") or "").strip()
if not text:
return
user = self.scope["user"]
msg = await self._create_message(self.chat, user, text)
# автору — сразу «прочитано»
await self._mark_read(self.chat, user)
payload = serialize_message(msg)
# ⬇ кладём chat_id для листа чатов (ChatListConsumer)
await self.channel_layer.group_send(
self.group_name,
{"type": "chat.message", "chat_id": self.chat_id, "payload": payload},
)
async def chat_message(self, event):
# detail-страница чата просто получает payload
await self.send_json(event["payload"])
# --- DB helpers ---
@database_sync_to_async
def _get_chat(self, chat_id: int) -> Chat:
return (
Chat.objects.select_related("department")
.prefetch_related("participants")
.get(pk=chat_id)
)
@database_sync_to_async
def _user_can_access(self, chat: Chat, user) -> bool:
if chat.type == "global":
return True
if chat.type == "department":
dept = chat.department
if not dept:
return False
return (
user.is_staff
or dept.head_id == user.id
or dept.active_employees.filter(id=user.id).exists()
)
return chat.participants.filter(id=user.id).exists()
@database_sync_to_async
def _create_message(self, chat: Chat, user, text: str) -> Message:
return Message.objects.create(chat=chat, author=user, content=text[:2000])
@database_sync_to_async
def _mark_read(self, chat: Chat, user):
last = chat.messages.order_by("-created_at").first()
ts = last.created_at if last else timezone.now()
ChatReadState.objects.update_or_create(
chat=chat, user=user, defaults={"last_read_at": ts}
)
class ChatListConsumer(AsyncJsonWebsocketConsumer):
"""
Список чатов подписывается на все chat_{id} пользователя, и каждое
новое сообщение получает как компактное обновление для карточки.
"""
async def connect(self):
user = self.scope.get("user")
if not user or isinstance(user, AnonymousUser) or not user.is_authenticated:
await self.close(code=4401); return
self.group_names = []
chat_ids = await self._get_available_chat_ids(user)
for cid in chat_ids:
g = f"chat_{cid}"
await self.channel_layer.group_add(g, self.channel_name)
self.group_names.append(g)
await self.accept()
async def disconnect(self, code):
for g in getattr(self, "group_names", []):
await self.channel_layer.group_discard(g, self.channel_name)
async def chat_message(self, event):
# Проксируем компактное событие в UI списка
await self.send_json(
{"type": "list_update", "chat_id": event.get("chat_id"), "message": event.get("payload")}
)
@database_sync_to_async
def _get_available_chat_ids(self, user):
departments = user.departments.all()
return list(
Chat.objects.filter(
Q(type="global")
| Q(type="department", department__in=departments)
| Q(type="private", participants=user)
).values_list("id", flat=True)
)