feat: Add DocumentComment model and related documents support

- Added related_documents ManyToMany field to Document model
- Created DocumentComment model with threading support (parent/replies)
- Created DocumentCommentViewSet with CRUD and permission checks
- Added endpoints for related documents:
  * GET /documents/{id}/related/ - list related documents
  * POST /documents/{id}/add_related/ - add related document
  * POST /documents/{id}/remove_related/ - remove related document
- DocumentComment features:
  * Threading support via parent field
  * Edit tracking (is_edited flag)
  * Author-only deletion (or staff)
  * Depth calculation for nested comments
- Created migration 0010 for new fields and model
- Registered DocumentCommentViewSet in router

All medium-priority features complete!
This commit is contained in:
igor_nadein 2026-02-28 14:38:59 +03:00
parent 90bc399d05
commit 58e341e295
5 changed files with 296 additions and 1 deletions

View File

@ -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):
"""Сериализатор для чтения документа.

View File

@ -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=<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).

View File

@ -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")

View File

@ -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')],
},
),
]

View File

@ -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