Corp/backend/api/v1/documents/serializers.py
lizerk 0217bc1a38 refactor: migrate comments to unified communications system
- Delete legacy Comment models (feed.Comment, documents.DocumentComment, requests_app.RequestComment)
- Create migration scripts to move data to communications.Message
- Add comments_helpers.py for unified comment operations
- Update API endpoints: /api/v1/posts/{id}/comments/, /api/v1/requests/{id}/comments/
- Fix notifications.0010_drop_old_tables migration with SeparateDatabaseAndState
- Implement real-time comments_count via GenericForeignKey annotations
- Update frontend API client to use new endpoints
- Update JavaScript files for new comment API routes
- Remove deprecated API endpoints and serializers
- Clean up signal handlers and admin registrations

Deployment tested: all migrations apply successfully on fresh database
2026-03-11 23:56:41 +03:00

541 lines
21 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 json
import logging
from typing import Any, Dict, Sequence, Iterable
from django.conf import settings
from django.http import QueryDict
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import UploadedFile
from django.template.defaultfilters import filesizeformat
from documents.models import Document, DocumentAcknowledgement
from rest_framework import serializers
from ..employees.serializers import EmployeeBriefSerializer
from .fields import FilerFileField as FilerFileSerializerField
logger = logging.getLogger(__name__)
User = get_user_model()
class RecipientIDsField(serializers.Field):
"""Принимает recipient_ids в форматах: список, JSON-строка или CSV.
Примеры:
- [1, 2, 3]
- "[1,2,3]"
- "1,2,3"
Возвращает:
list[int]: Нормализованный список целых ID.
Raises:
serializers.ValidationError: Некорректный формат/значения.
"""
def to_internal_value(self, data: Any) -> list[int]:
"""Парсит raw-значение из запроса в список int.
При multipart (QueryDict) поддерживает repeat-params: recipient_ids=1&recipient_ids=2...
"""
# 1) Спец. случай: multipart/QueryDict с repeat-params
# DRF в обычное Field передаёт "последнее" значение, поэтому
# забираем полный список через parent.initial_data.getlist(self.field_name)
parent_data = getattr(self.parent, "initial_data", None) # type: ignore[attr-defined]
if isinstance(parent_data, QueryDict):
values = parent_data.getlist(self.field_name) # type: ignore[attr-defined]
if len(values) > 1:
data = values
elif len(values) == 1 and not isinstance(data, (list, tuple)):
# один элемент: оставим строкой — ниже распарсится как JSON/CSV/число
data = values[0]
# 2) Уже список/кортеж?
if isinstance(data, (list, tuple)):
# если это ['1','2','3'] — ок; если это ['[1,2]'] — распакуем как строку
if (
len(data) == 1
and isinstance(data[0], str)
and (data[0].strip().startswith("[") or "," in data[0])
):
data = data[0]
else:
vals = list(data)
# 3) Строка: JSON-массив или CSV или одно число
if isinstance(data, str):
s = data.strip()
try:
if s.startswith("[") and s.endswith("]"):
parsed = json.loads(s)
if not isinstance(parsed, list):
raise serializers.ValidationError("Ожидается JSON-массив ID.")
vals = parsed
elif "," in s:
vals = [p.strip() for p in s.split(",") if p.strip()]
else:
vals = [s]
except serializers.ValidationError:
raise
except Exception as e:
raise serializers.ValidationError(
f"Неверный формат recipient_ids: {e!s}"
)
elif data in (None, ""):
vals = []
elif not isinstance(data, (list, tuple)):
raise serializers.ValidationError(
"Ожидается список, JSON-строка или CSV-строка."
)
# Приведение к int
try:
return [int(x) for x in vals]
except Exception:
raise serializers.ValidationError(
"ID получателей должны быть целыми числами."
)
def to_representation(self, value: Iterable[int]) -> list[int]:
"""Отдаёт список ID (на всякий случай; поле write_only)."""
if value is None:
return []
return list(value)
class DepartmentBriefSerializer(serializers.Serializer):
"""Краткий сериализатор отдела."""
id = serializers.IntegerField(read_only=True)
name = serializers.CharField(read_only=True)
class FolderSerializer(serializers.Serializer):
"""Сериализатор для папки filer.Folder."""
id = serializers.IntegerField(read_only=True)
name = serializers.CharField()
parent_id = serializers.IntegerField(source='parent.id', read_only=True, allow_null=True)
path = serializers.SerializerMethodField()
document_count = serializers.IntegerField(read_only=True, default=0)
def get_path(self, obj) -> str:
"""Возвращает полный путь папки."""
path_parts = []
current = obj
while current:
path_parts.insert(0, current.name)
current = current.parent
return ' / '.join(path_parts)
class FolderBriefSerializer(serializers.Serializer):
"""Краткий сериализатор папки для вложенного отображения."""
id = serializers.IntegerField(read_only=True)
name = serializers.CharField(read_only=True)
class DocumentTagSerializer(serializers.Serializer):
"""Сериализатор для тегов документов."""
id = serializers.IntegerField(read_only=True)
name = serializers.CharField(max_length=100)
slug = serializers.SlugField(required=False, max_length=100)
color = serializers.CharField(required=False)
description = serializers.CharField(required=False, allow_blank=True)
document_count = serializers.SerializerMethodField()
def get_document_count(self, obj) -> int:
"""Возвращает количество документов с этим тегом."""
return obj.documents.count() if hasattr(obj, 'documents') else 0
def validate_slug(self, value):
"""Проверяет уникальность slug."""
from documents.models import DocumentTag
# Проверяем дубликаты (исключая текущий объект при обновлении)
qs = DocumentTag.objects.filter(slug=value)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise serializers.ValidationError("Tag with this slug already exists.")
return value
def create(self, validated_data):
"""Создаёт новый тег документа."""
from documents.models import DocumentTag
return DocumentTag.objects.create(**validated_data)
def update(self, instance, validated_data):
"""Обновляет тег документа."""
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
class VersionSerializer(serializers.Serializer):
"""Сериализатор для версий документа (django-reversion)."""
id = serializers.IntegerField(read_only=True)
revision_id = serializers.IntegerField(source='revision.id', read_only=True)
date_created = serializers.DateTimeField(source='revision.date_created', read_only=True)
user = serializers.SerializerMethodField()
comment = serializers.CharField(source='revision.comment', read_only=True, allow_blank=True)
# Данные версии
data = serializers.SerializerMethodField()
def get_user(self, obj) -> dict | None:
"""Возвращает информацию о пользователе, создавшем версию."""
if not obj.revision.user:
return None
user = obj.revision.user
return {
'id': user.id,
'full_name': f'{user.last_name} {user.first_name}'.strip(),
'avatar_url': getattr(user, 'avatar_url', None),
}
def get_data(self, obj) -> dict:
"""Возвращает данные версии документа."""
return obj.field_dict
class ActivityItemSerializer(serializers.Serializer):
"""Сериализатор для элемента timeline активности."""
type = serializers.CharField() # 'version', 'audit', 'acknowledgement'
timestamp = serializers.DateTimeField()
user = serializers.DictField(allow_null=True)
action = serializers.CharField()
details = serializers.DictField(allow_null=True)
class DocumentReadSerializer(serializers.ModelSerializer):
"""Сериализатор для чтения документа.
Включает список получателей и флаг, ознакомился ли текущий пользователь.
"""
created_by = EmployeeBriefSerializer(read_only=True)
uploaded_by = EmployeeBriefSerializer(read_only=True)
modified_by = EmployeeBriefSerializer(read_only=True)
recipients = EmployeeBriefSerializer(many=True, read_only=True)
departments = DepartmentBriefSerializer(many=True, read_only=True)
folder = FolderBriefSerializer(read_only=True)
folder_path = serializers.CharField(read_only=True)
tags = DocumentTagSerializer(many=True, read_only=True)
file_url = FilerFileSerializerField(source="file", read_only=True)
file_name = serializers.CharField(source='file.name', read_only=True, allow_null=True)
file_size = serializers.IntegerField(source='file.size', read_only=True, allow_null=True)
is_acknowledged = serializers.SerializerMethodField()
class Meta:
model = Document
fields = (
"id",
"title",
"description",
"extracted_text", # Для полнотекстового поиска
"folder", # Папка документа
"folder_path", # Полный путь папки
"tags", # Теги
"created_by", # Создатель документа
"created_at", # Дата создания
"uploaded_by",
"uploaded_at",
"modified_by",
"modified_at",
"sent_to_all",
"acknowledgement_required", # Требуется ли ознакомление
"departments",
"recipients",
"file_url",
"file_name",
"file_size",
"is_acknowledged",
)
def get_is_acknowledged(self, obj: Document) -> bool:
"""Возвращает флаг ознакомления текущего пользователя.
Args:
obj (Document): Документ.
Returns:
bool: True, если текущий пользователь уже ознакомился.
"""
annotated = getattr(obj, "_is_acknowledged", None)
if annotated is not None:
return bool(annotated)
request = self.context.get("request")
if not request or not request.user.is_authenticated:
return False
return DocumentAcknowledgement.objects.filter(
document=obj, user=request.user
).exists()
class DocumentWriteSerializer(serializers.ModelSerializer):
"""Сериализатор для записи/обновления документа.
Поддерживает:
- загрузку файла (multipart) через django-filer
- sent_to_all
- department_ids (отделы-получатели)
- recipient_ids (repeat/JSON/CSV) при sent_to_all=false
- tag_ids (теги)
"""
recipient_ids = RecipientIDsField(
write_only=True,
required=False,
help_text="Список ID сотрудников (repeat/JSON/CSV) при sent_to_all=false.",
)
department_ids = RecipientIDsField(
write_only=True,
required=False,
help_text="Список ID отделов-получателей.",
)
tag_ids = RecipientIDsField(
write_only=True,
required=False,
help_text="Список ID тегов для привязки к документу.",
)
file = FilerFileSerializerField(
required=False,
allow_null=True,
help_text="Файл для загрузки через django-filer"
)
class Meta:
model = Document
fields = (
"id",
"title",
"description",
"file",
"folder",
"extracted_text",
"sent_to_all",
"acknowledgement_required",
"department_ids",
"recipient_ids",
"tag_ids",
)
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
"""Согласованность полей.
Args:
attrs (dict): Данные после field-level валидаций.
Returns:
dict: Те же данные.
Raises:
serializers.ValidationError: Если sent_to_all=false
и нет recipient_ids или department_ids.
"""
if self.instance is None and not attrs.get("file"):
raise serializers.ValidationError({"file": "Обязательное поле."})
sent_to_all = attrs.get("sent_to_all", True)
recipient_ids = attrs.get("recipient_ids", [])
department_ids = attrs.get("department_ids", [])
if sent_to_all is False and not recipient_ids and not department_ids:
raise serializers.ValidationError(
{
"recipient_ids": "Укажите получателей, отделы или установите sent_to_all=true."
}
)
return attrs
def _set_departments(self, doc: Document, department_ids: Sequence[int]) -> int:
"""Привязывает отделы-получатели по списку ID.
Args:
doc (Document): Документ.
department_ids (Sequence[int]): Идентификаторы отделов.
Returns:
int: Количество привязанных отделов.
"""
from employees.models import Department
logger.info(
f"[serializers] _set_departments doc={doc.id} "
f"department_ids={list(department_ids)}"
)
if not department_ids:
doc.departments.clear()
logger.info(f"[serializers] Cleared departments for doc={doc.id}")
return 0
departments = Department.objects.filter(id__in=set(department_ids))
count = departments.count()
logger.info(
f"[serializers] Found {count} departments from "
f"{len(department_ids)} requested IDs"
)
doc.departments.set(departments)
logger.info(
f"[serializers] Set {count} departments for doc={doc.id}"
)
return count
def _set_recipients(self, doc: Document, recipient_ids: Sequence[int]) -> int:
"""Привязывает получателей по списку ID (только активные).
Args:
doc (Document): Документ.
recipient_ids (Sequence[int]): Идентификаторы пользователей.
Returns:
int: Количество привязанных получателей.
"""
logger.info(
f"[serializers] _set_recipients doc={doc.id} "
f"recipient_ids={list(recipient_ids)}"
)
if not recipient_ids:
doc.recipients.clear()
logger.info(f"[serializers] Cleared recipients for doc={doc.id}")
return 0
users = User.objects.filter(is_active=True, id__in=set(recipient_ids))
count = users.count()
logger.info(
f"[serializers] Found {count} active users from "
f"{len(recipient_ids)} requested IDs"
)
doc.recipients.set(users)
logger.info(
f"[serializers] Set {count} recipients for doc={doc.id}"
)
return count
def _set_tags(self, doc: Document, tag_ids: Sequence[int]) -> int:
"""Привязывает теги по списку ID.
Args:
doc (Document): Документ.
tag_ids (Sequence[int]): Идентификаторы тегов.
Returns:
int: Количество привязанных тегов.
"""
from documents.models import DocumentTag
logger.info(
f"[serializers] _set_tags doc={doc.id} tag_ids={list(tag_ids)}"
)
if not tag_ids:
doc.tags.clear()
logger.info(f"[serializers] Cleared tags for doc={doc.id}")
return 0
tags = DocumentTag.objects.filter(id__in=set(tag_ids))
count = tags.count()
logger.info(
f"[serializers] Found {count} tags from {len(tag_ids)} "
f"requested IDs"
)
doc.tags.set(tags)
logger.info(f"[serializers] Set {count} tags for doc={doc.id}")
return count
def create(self, validated_data: Dict[str, Any]) -> Document:
"""Создание документа с корректной установкой получателей и уведомлением.
Args:
validated_data (dict): Данные.
Returns:
Document: Созданный документ.
"""
recipient_ids = validated_data.pop("recipient_ids", [])
department_ids = validated_data.pop("department_ids", [])
tag_ids = validated_data.pop("tag_ids", [])
request = self.context.get("request")
uploader = getattr(request, "user", None)
sent_to_all = validated_data.get("sent_to_all", True)
logger.info(
f"[serializers] Creating document sent_to_all={sent_to_all} "
f"department_ids={list(department_ids)} "
f"recipient_ids={list(recipient_ids)} "
f"tag_ids={list(tag_ids)}"
)
# Создаём документ - сигналы из notification_signals.py сработают автоматически
doc = Document.objects.create(uploaded_by=uploader, **validated_data) # type: ignore[arg-type]
logger.info(
f"[serializers] Document created id={doc.id} "
f"sent_to_all={doc.sent_to_all}"
)
# Для индивидуальной рассылки устанавливаем получателей
# Это вызовет m2m_changed сигнал
if validated_data.get("sent_to_all") is False:
if department_ids:
logger.info(
f"[serializers] Setting departments for doc={doc.id}"
)
self._set_departments(doc, department_ids)
if recipient_ids:
logger.info(
f"[serializers] Setting recipients for doc={doc.id}"
)
self._set_recipients(doc, recipient_ids)
# Устанавливаем теги (независимо от sent_to_all)
if tag_ids:
logger.info(f"[serializers] Setting tags for doc={doc.id}")
self._set_tags(doc, tag_ids)
logger.info(f"[serializers] Document creation complete id={doc.id}")
return doc
def update(self, instance: Document, validated_data: Dict[str, Any]) -> Document:
"""Частичное обновление документа.
Args:
instance (Document): Экземпляр.
validated_data (dict): Данные.
Returns:
Document: Обновлённый документ.
"""
recipient_ids = validated_data.pop("recipient_ids", None)
department_ids = validated_data.pop("department_ids", None)
tag_ids = validated_data.pop("tag_ids", None)
for f in ("title", "description", "sent_to_all", "file", "folder"):
if f in validated_data:
setattr(instance, f, validated_data[f])
instance.save()
# очищаем получателей и отделы при sent_to_all=true
if "sent_to_all" in validated_data and instance.sent_to_all:
instance.recipients.clear()
instance.departments.clear()
else:
if department_ids is not None:
self._set_departments(instance, department_ids)
if recipient_ids is not None:
self._set_recipients(instance, recipient_ids)
# Обновляем теги (независимо от sent_to_all)
if tag_ids is not None:
self._set_tags(instance, tag_ids)
return instance