diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index 0fa6c1fb..2e46051a 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -212,6 +212,24 @@ class ActivityItemSerializer(serializers.Serializer): 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 + + class DocumentReadSerializer(serializers.ModelSerializer): """Сериализатор для чтения документа. diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index 467688cd..89abe23c 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -12,12 +12,13 @@ from documents.models import ( DocumentTag, DocumentType, Cabinet, + DocumentComment, ) from easy_thumbnails.files import get_thumbnailer from filer.models import Folder import reversion from reversion.models import Version -from rest_framework import status +from rest_framework import status, serializers as rest_serializers from rest_framework.decorators import action from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated @@ -36,6 +37,7 @@ from .serializers import ( DocumentTagSerializer, DocumentTypeSerializer, CabinetSerializer, + DocumentCommentSerializer, ) User = get_user_model() @@ -574,6 +576,171 @@ class DocumentViewSet(ModelViewSet): status=status.HTTP_400_BAD_REQUEST ) + # ------------------------------------------------------------------------- + # RELATED DOCUMENTS ENDPOINTS + # ------------------------------------------------------------------------- + + @action(detail=True, methods=['get']) + def related(self, request, pk=None): + """Получить связанные документы. + + Returns: + Список связанных документов + """ + document = self.get_object() + related = document.related_documents.all() + serializer = DocumentReadSerializer( + related, + many=True, + context={'request': request} + ) + return Response(serializer.data) + + @action(detail=True, methods=['post']) + def add_related(self, request, pk=None): + """Добавить связанный документ. + + Body: + {"document_id": 123} + + Returns: + {"status": "added"} + """ + document = self.get_object() + related_id = request.data.get('document_id') + + if not related_id: + return Response( + {'error': 'document_id обязателен'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if related_id == document.pk: + return Response( + {'error': 'Нельзя связать документ с самим собой'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + related_doc = Document.objects.get(pk=related_id) + document.related_documents.add(related_doc) + return Response({'status': 'added'}) + except Document.DoesNotExist: + return Response( + {'error': 'Документ не найден'}, + status=status.HTTP_404_NOT_FOUND + ) + + @action(detail=True, methods=['post']) + def remove_related(self, request, pk=None): + """Удалить связанный документ. + + Body: + {"document_id": 123} + + Returns: + {"status": "removed"} + """ + document = self.get_object() + related_id = request.data.get('document_id') + + if not related_id: + return Response( + {'error': 'document_id обязателен'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + related_doc = Document.objects.get(pk=related_id) + document.related_documents.remove(related_doc) + return Response({'status': 'removed'}) + except Document.DoesNotExist: + return Response( + {'error': 'Документ не найден'}, + status=status.HTTP_404_NOT_FOUND + ) + + +class DocumentCommentViewSet(ModelViewSet): + """CRUD для комментариев к документам. + + Поддерживает вложенные ответы (threading) через поле parent. + """ + serializer_class = DocumentCommentSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Возвращает комментарии, опционально фильтрованные по документу. + + Параметры: + ?document_id= - показать только комментарии к этому документу + ?root=true - показать только корневые комментарии (без parent) + """ + qs = DocumentComment.objects.all().select_related('author', 'document', 'parent') + + document_id = self.request.query_params.get('document_id') + root = self.request.query_params.get('root', '').lower() == 'true' + + if document_id: + qs = qs.filter(document_id=document_id) + + if root: + qs = qs.filter(parent__isnull=True) + + return qs.order_by('created_at') + + @transaction.atomic + def perform_create(self, serializer): + """Автоматически устанавливаем author при создании.""" + # Получаем document_id и parent_id из request.data + document_id = self.request.data.get('document_id') + parent_id = self.request.data.get('parent_id') + + if not document_id: + raise rest_serializers.ValidationError({'document_id': 'Это поле обязательно.'}) + + try: + document = Document.objects.get(pk=document_id) + except Document.DoesNotExist: + raise rest_serializers.ValidationError({'document_id': 'Документ не найден.'}) + + parent = None + if parent_id: + try: + parent = DocumentComment.objects.get(pk=parent_id, document=document) + except DocumentComment.DoesNotExist: + raise rest_serializers.ValidationError({'parent_id': 'Родительский комментарий не найден.'}) + + serializer.save( + author=self.request.user, + document=document, + parent=parent + ) + + @transaction.atomic + def perform_update(self, serializer): + """При обновлении помечаем комментарий как отредактированный.""" + serializer.save(is_edited=True) + + def destroy(self, request, *args, **kwargs): + """Разрешаем удалять только свои комментарии (или staff).""" + comment = self.get_object() + + if comment.author != request.user and not request.user.is_staff: + return Response( + {'error': 'Вы можете удалять только свои комментарии'}, + status=status.HTTP_403_FORBIDDEN + ) + + return super().destroy(request, *args, **kwargs) + + @action(detail=True, methods=['get']) + def replies(self, request, pk=None): + """Получить все ответы на комментарий.""" + comment = self.get_object() + replies = comment.replies.all().select_related('author') + serializer = self.get_serializer(replies, many=True) + return Response(serializer.data) class FolderViewSet(ModelViewSet): """CRUD для папок документов (filer.Folder). diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 4431386c..6b305e01 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -18,6 +18,7 @@ from .documents.views import ( DocumentTagViewSet, DocumentTypeViewSet, CabinetViewSet, + DocumentCommentViewSet, ) from .employees.views import ( DepartmentRoleViewSet, @@ -51,6 +52,7 @@ router.register(r"folders", FolderViewSet, basename="folders") router.register(r"document-tags", DocumentTagViewSet, basename="document-tags") router.register(r"document-types", DocumentTypeViewSet, basename="document-types") router.register(r"cabinets", CabinetViewSet, basename="cabinets") +router.register(r"document-comments", DocumentCommentViewSet, basename="document-comments") router.register(r"requests", RequestViewSet, basename="request") diff --git a/backend/documents/migrations/0010_document_related_documents_documentcomment.py b/backend/documents/migrations/0010_document_related_documents_documentcomment.py new file mode 100644 index 00000000..d6c3524f --- /dev/null +++ b/backend/documents/migrations/0010_document_related_documents_documentcomment.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.4 on 2026-02-28 11:38 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('documents', '0009_document_acknowledgement_required'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='document', + name='related_documents', + field=models.ManyToManyField(blank=True, help_text='Документы, связанные с этим', to='documents.document', verbose_name='Связанные документы'), + ), + migrations.CreateModel( + name='DocumentComment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField(verbose_name='Текст комментария')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлен')), + ('is_edited', models.BooleanField(default=False, verbose_name='Отредактирован')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='document_comments', to=settings.AUTH_USER_MODEL, verbose_name='Автор')), + ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='documents.document', verbose_name='Документ')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='documents.documentcomment', verbose_name='Родительский комментарий')), + ], + options={ + 'verbose_name': 'Комментарий к документу', + 'verbose_name_plural': 'Комментарии к документам', + 'ordering': ['created_at'], + 'indexes': [models.Index(fields=['document', 'created_at'], name='documents_d_documen_ab0b16_idx'), models.Index(fields=['author', '-created_at'], name='documents_d_author__e98132_idx')], + }, + ), + ] diff --git a/backend/documents/models.py b/backend/documents/models.py index 7311e25c..dbcc9dde 100644 --- a/backend/documents/models.py +++ b/backend/documents/models.py @@ -150,6 +150,15 @@ class Document(models.Model): help_text=_('Если `Разослать всем` отключено, документ пойдет только этим'), related_name='document_recipients' ) + + # Связанные документы + related_documents = models.ManyToManyField( + 'self', + verbose_name=_('Связанные документы'), + blank=True, + symmetrical=True, + help_text=_('Документы, связанные с этим') + ) # ------------------------------------------------------------------------- # FSM TRANSITIONS (переходы между состояниями) @@ -581,3 +590,62 @@ class DocumentAuditLog(models.Model): def __str__(self): return f'{self.user} {self.get_action_display()} {self.document} @ {self.timestamp:%d.%m.%Y %H:%M}' + + +class DocumentComment(models.Model): + """ + Комментарии к документам. + Поддерживает вложенные ответы (threading). + """ + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name='comments', + verbose_name=_('Документ') + ) + author = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='document_comments', + verbose_name=_('Автор') + ) + text = models.TextField(_('Текст комментария')) + + # Вложенные ответы + parent = models.ForeignKey( + 'self', + verbose_name=_('Родительский комментарий'), + null=True, + blank=True, + on_delete=models.CASCADE, + related_name='replies' + ) + + created_at = models.DateTimeField(_('Создан'), auto_now_add=True) + updated_at = models.DateTimeField(_('Обновлен'), auto_now=True) + + # Редактирование + is_edited = models.BooleanField(_('Отредактирован'), default=False) + + class Meta: + verbose_name = _('Комментарий к документу') + verbose_name_plural = _('Комментарии к документам') + ordering = ['created_at'] # Старые сверху + indexes = [ + models.Index(fields=['document', 'created_at']), + models.Index(fields=['author', '-created_at']), + ] + + def __str__(self): + preview = self.text[:50] + '...' if len(self.text) > 50 else self.text + return f'{self.author}: {preview}' + + @property + def depth(self): + """Возвращает уровень вложенности комментария.""" + depth = 0 + current = self.parent + while current: + depth += 1 + current = current.parent + return depth