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 DocumentCommentSerializer(serializers.Serializer): """Сериализатор для комментариев к документам.""" id = serializers.IntegerField(read_only=True) document_id = serializers.IntegerField(read_only=True) author = EmployeeBriefSerializer(read_only=True) text = serializers.CharField() parent_id = serializers.IntegerField(required=False, allow_null=True) created_at = serializers.DateTimeField(read_only=True) updated_at = serializers.DateTimeField(read_only=True) is_edited = serializers.BooleanField(read_only=True) depth = serializers.IntegerField(read_only=True) replies_count = serializers.SerializerMethodField() def get_replies_count(self, obj) -> int: """Возвращает количество ответов на комментарий.""" return obj.replies.count() if hasattr(obj, 'replies') else 0 def create(self, validated_data): """Создаёт новый комментарий.""" from documents.models import Document, DocumentComment # document_id и parent_id должны быть установлены из контекста view return DocumentComment.objects.create(**validated_data) def update(self, instance, validated_data): """Обновляет комментарий.""" # При обновлении текста устанавливаем is_edited = True if 'text' in validated_data and validated_data['text'] != instance.text: instance.is_edited = True for attr, value in validated_data.items(): setattr(instance, attr, value) instance.save() return instance 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