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:
parent
90bc399d05
commit
58e341e295
@ -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):
|
||||
"""Сериализатор для чтения документа.
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user