328 lines
11 KiB
Python
328 lines
11 KiB
Python
# communications/serialization.py
|
||
"""
|
||
Утилиты сериализации для модуля communications.
|
||
Вынесены из consumers.py для переиспользования в API, WebSocket и других
|
||
местах.
|
||
"""
|
||
|
||
from django.conf import settings
|
||
|
||
|
||
def _get_author_url(author) -> str:
|
||
"""
|
||
Получает URL профиля автора используя настройку из settings.
|
||
|
||
Settings:
|
||
COMMUNICATIONS_AUTHOR_URL_PATTERN (str): Шаблон URL для профиля автора.
|
||
По умолчанию: '/api/v1/employees/{id}/' (проектно-специфичный)
|
||
Для standalone: '/users/{id}/' или None (пустая строка)
|
||
Должен содержать placeholder {id} для подстановки ID пользователя
|
||
|
||
Args:
|
||
author: Объект пользователя (User model)
|
||
|
||
Returns:
|
||
str: URL профиля автора или пустая строка
|
||
|
||
Examples:
|
||
>>> # settings.py
|
||
>>> COMMUNICATIONS_AUTHOR_URL_PATTERN = '/users/{id}/'
|
||
>>> _get_author_url(user)
|
||
'/users/123/'
|
||
"""
|
||
if not author:
|
||
return ""
|
||
|
||
url_pattern = getattr(
|
||
settings,
|
||
"COMMUNICATIONS_AUTHOR_URL_PATTERN",
|
||
"/api/v1/employees/{id}/", # backward compatibility
|
||
)
|
||
|
||
if url_pattern is None:
|
||
return ""
|
||
|
||
try:
|
||
return url_pattern.format(id=author.id)
|
||
except (KeyError, ValueError, AttributeError):
|
||
return ""
|
||
|
||
|
||
def _is_message_read(m) -> bool:
|
||
"""True, если сообщение прочитал хотя бы один другой участник."""
|
||
chat = getattr(m, "chat", None)
|
||
if not chat or not getattr(m, "id", None) or not getattr(m, "author_id", None):
|
||
return False
|
||
|
||
prefetched = getattr(chat, "_prefetched_objects_cache", {})
|
||
read_states = prefetched.get("read_states")
|
||
if read_states is not None:
|
||
return any(
|
||
state.user_id != m.author_id
|
||
and state.last_read_message_id
|
||
and state.last_read_message_id >= m.id
|
||
for state in read_states
|
||
)
|
||
|
||
return (
|
||
chat.read_states.exclude(user_id=m.author_id)
|
||
.filter(last_read_message_id__gte=m.id)
|
||
.exists()
|
||
)
|
||
|
||
|
||
def _get_user_avatar_url(user) -> str:
|
||
if not user:
|
||
return ""
|
||
|
||
try:
|
||
if getattr(user, "avatar", None) and user.avatar:
|
||
return user.avatar.url
|
||
except Exception:
|
||
return ""
|
||
|
||
return ""
|
||
|
||
|
||
def serialize_message_reader(user) -> dict:
|
||
"""Краткие данные пользователя для read receipt сообщения."""
|
||
return {
|
||
"id": user.id,
|
||
"name": user.get_full_name() or user.username,
|
||
"avatar": _get_user_avatar_url(user),
|
||
}
|
||
|
||
|
||
def serialize_poll(poll, user=None) -> dict:
|
||
from .polls import poll_is_effectively_closed
|
||
|
||
user_option_ids = []
|
||
if user and getattr(user, "is_authenticated", False):
|
||
user_option_ids = list(
|
||
poll.votes.filter(voter=user).values_list("option_id", flat=True)
|
||
)
|
||
|
||
total_voters = max(poll.total_voters, 0)
|
||
options = []
|
||
for option in poll.options.all():
|
||
percentage = (
|
||
round(option.vote_count / total_voters * 100, 1) if total_voters else 0
|
||
)
|
||
options.append(
|
||
{
|
||
"id": option.id,
|
||
"text": option.text,
|
||
"position": option.position,
|
||
"vote_count": option.vote_count,
|
||
"percentage": percentage,
|
||
}
|
||
)
|
||
|
||
return {
|
||
"id": poll.id,
|
||
"message_id": poll.message_id,
|
||
"author_id": poll.author_id,
|
||
"question": poll.question,
|
||
"is_anonymous": poll.is_anonymous,
|
||
"is_multiple_choice": poll.is_multiple_choice,
|
||
"is_quiz": poll.is_quiz,
|
||
"is_closed": poll_is_effectively_closed(poll),
|
||
"closes_at": poll.closes_at.isoformat() if poll.closes_at else None,
|
||
"allows_custom_answers": poll.allows_custom_answers,
|
||
"total_voters": total_voters,
|
||
"user_voted_option_ids": user_option_ids,
|
||
"options": options,
|
||
}
|
||
|
||
|
||
def _serialize_reply_preview(reply_msg) -> dict:
|
||
is_deleted = bool(getattr(reply_msg, "is_deleted", False))
|
||
|
||
return {
|
||
"id": reply_msg.id,
|
||
"content": reply_msg.content[:100] if reply_msg.content else "",
|
||
"author_name": (
|
||
reply_msg.author.get_full_name() if reply_msg.author else "Неизвестный"
|
||
),
|
||
"is_deleted": is_deleted,
|
||
"has_attachments": bool(getattr(reply_msg, "has_attachments", False)),
|
||
}
|
||
|
||
|
||
def _get_message_read_by(m) -> list[dict]:
|
||
"""Список участников, которые дочитали сообщение."""
|
||
chat = getattr(m, "chat", None)
|
||
if not chat or not getattr(m, "id", None) or not getattr(m, "author_id", None):
|
||
return []
|
||
|
||
prefetched = getattr(chat, "_prefetched_objects_cache", {})
|
||
read_states = prefetched.get("read_states")
|
||
if read_states is None:
|
||
read_states = chat.read_states.select_related("user").filter(
|
||
last_read_message_id__gte=m.id
|
||
)
|
||
|
||
readers = []
|
||
for state in read_states:
|
||
if state.user_id == m.author_id:
|
||
continue
|
||
if not state.last_read_message_id or state.last_read_message_id < m.id:
|
||
continue
|
||
|
||
user = getattr(state, "user", None)
|
||
if not user:
|
||
continue
|
||
|
||
readers.append(serialize_message_reader(user))
|
||
|
||
readers.sort(key=lambda item: item["name"].lower())
|
||
return readers
|
||
|
||
|
||
def _get_message_linked_tasks(m, user) -> list[dict]:
|
||
if not user or not getattr(user, "is_authenticated", False):
|
||
return []
|
||
|
||
from tasks.models import TaskLinkedObjectKind
|
||
from tasks.presentation import linked_task_badges_for_object
|
||
|
||
return linked_task_badges_for_object(
|
||
obj=m,
|
||
kind=TaskLinkedObjectKind.MESSAGE,
|
||
user=user,
|
||
)
|
||
|
||
|
||
def serialize_message(m, user=None, include_linked_tasks=False) -> dict:
|
||
"""
|
||
Сериализация сообщения с поддержкой всех полей.
|
||
|
||
Поддерживаемые фичи:
|
||
- Базовые поля (контент, автор, дата)
|
||
- Редактирование и удаление
|
||
- Пересылка сообщений (forward)
|
||
- Реакции
|
||
- Вложения (attachments)
|
||
- Голосования (polls)
|
||
- Ответы на сообщения (reply_to)
|
||
"""
|
||
author = m.author
|
||
author_name = author.get_full_name() or author.username
|
||
avatar = _get_user_avatar_url(author)
|
||
|
||
# Базовые поля
|
||
data = {
|
||
"id": m.id,
|
||
"content": m.content,
|
||
"mentions": m.mentions or [],
|
||
"author_id": author.id if author else None,
|
||
"author_name": author_name,
|
||
"author_url": _get_author_url(author),
|
||
"avatar": avatar,
|
||
"created": m.created_at.strftime("%d.%m.%Y %H:%M"),
|
||
"created_ts": int(m.created_at.timestamp() * 1000),
|
||
# Статусные поля
|
||
"is_edited": m.is_edited,
|
||
"edited_at": m.edited_at.isoformat() if m.edited_at else None,
|
||
"is_read": _is_message_read(m),
|
||
"read_by": _get_message_read_by(m),
|
||
"is_deleted": m.is_deleted,
|
||
"is_pinned": m.is_pinned,
|
||
"is_forwarded": m.is_forwarded,
|
||
"is_system": m.is_system,
|
||
"has_attachments": m.has_attachments,
|
||
}
|
||
data["read_count"] = len(data["read_by"])
|
||
if include_linked_tasks:
|
||
data["linked_tasks"] = _get_message_linked_tasks(m, user)
|
||
|
||
# Информация о пересылке (используем forward_metadata)
|
||
if m.is_forwarded:
|
||
try:
|
||
metadata = m.forward_metadata
|
||
forwarded_data = {
|
||
"author_id": metadata.original_author.id
|
||
if metadata.original_author
|
||
else None,
|
||
"author_name": (
|
||
metadata.original_author.get_full_name()
|
||
if metadata.original_author
|
||
else metadata.original_author.username
|
||
if metadata.original_author
|
||
else "Неизвестно"
|
||
),
|
||
"message_id": metadata.original_message_id
|
||
if metadata.original_message
|
||
else None,
|
||
}
|
||
|
||
# Добавляем дату оригинального сообщения
|
||
if metadata.original_created_at:
|
||
forwarded_data["created_at"] = metadata.original_created_at.strftime(
|
||
"%d.%m.%Y %H:%M"
|
||
)
|
||
forwarded_data["created_ts"] = int(
|
||
metadata.original_created_at.timestamp() * 1000
|
||
)
|
||
|
||
# Добавляем название исходного чата
|
||
if metadata.original_chat_name:
|
||
forwarded_data["chat_name"] = metadata.original_chat_name
|
||
|
||
data["forwarded_from"] = forwarded_data
|
||
except Exception:
|
||
# Если метаданных нет, просто не добавляем информацию о пересылке
|
||
pass
|
||
|
||
# Реакции - сериализуем из связанной модели MessageReaction
|
||
data["reactions_summary"] = m.get_reactions_summary()
|
||
|
||
# Вложения - всегда включаем поле attachments
|
||
attachments = []
|
||
if m.has_attachments:
|
||
for att in m.attachments.all():
|
||
attachments.append(
|
||
{
|
||
"id": att.id,
|
||
"file_name": att.file_name,
|
||
"file_type": att.file_type,
|
||
"file_url": att.file.url,
|
||
"file_size": att.file_size,
|
||
"mime_type": att.mime_type,
|
||
"width": att.width, # Размеры для CSS aspect-ratio
|
||
"height": att.height,
|
||
"thumbnail": (
|
||
att.thumbnail.url if getattr(att, "thumbnail", None) else None
|
||
),
|
||
}
|
||
)
|
||
data["attachments"] = attachments
|
||
|
||
# Голосование
|
||
if hasattr(m, "poll"):
|
||
data["poll"] = serialize_poll(m.poll, user=user)
|
||
|
||
# Ответ на сообщение
|
||
if m.reply_to_id:
|
||
try:
|
||
reply_msg = m.reply_to if hasattr(m, "reply_to") else None
|
||
if not reply_msg:
|
||
from communications.models import Message as Msg
|
||
|
||
reply_msg = Msg.objects.select_related("author").get(pk=m.reply_to_id)
|
||
|
||
data["reply_to"] = _serialize_reply_preview(reply_msg)
|
||
except Exception:
|
||
# Если не удалось загрузить reply_to, просто пропускаем
|
||
pass
|
||
|
||
# Информация о пересылке (legacy)
|
||
if m.is_forwarded and hasattr(m, "forward_info"):
|
||
fw = m.forward_info
|
||
data["forward_info"] = {
|
||
"original_author": fw.preserved_author_name,
|
||
"forward_count": fw.forward_count,
|
||
}
|
||
|
||
return data
|