feat: Add ViewSets for DocumentTag, DocumentType, Cabinet and Thumbnails API

- Created DocumentTagViewSet with CRUD and document listing
- Created DocumentTypeViewSet with CRUD and filtering (active/inactive)
- Created CabinetViewSet for virtual document collections with hierarchy
- Added thumbnail endpoint to DocumentViewSet (GET /documents/{id}/thumbnail/?size=medium)
- Registered all new ViewSets in router:
  * /api/v1/document-tags/
  * /api/v1/document-types/
  * /api/v1/cabinets/
- Thumbnail sizes: admin_thumbnail, small, medium, large
- All ViewSets support search and filtering
- Cabinet ViewSet has add_document/remove_document actions

This completes high-priority missing API endpoints for frontend.
This commit is contained in:
igor_nadein 2026-02-28 14:34:17 +03:00
parent 7739e7520e
commit 90bc399d05
2 changed files with 270 additions and 2 deletions

View File

@ -5,7 +5,15 @@ from typing import Any
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Count, Exists, OuterRef, QuerySet, Q
from documents.models import Document, DocumentAcknowledgement
from django.http import FileResponse, Http404
from documents.models import (
Document,
DocumentAcknowledgement,
DocumentTag,
DocumentType,
Cabinet,
)
from easy_thumbnails.files import get_thumbnailer
from filer.models import Folder
import reversion
from reversion.models import Version
@ -25,6 +33,9 @@ from .serializers import (
FolderSerializer,
VersionSerializer,
ActivityItemSerializer,
DocumentTagSerializer,
DocumentTypeSerializer,
CabinetSerializer,
)
User = get_user_model()
@ -518,6 +529,51 @@ class DocumentViewSet(ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=True, methods=['get'])
def thumbnail(self, request, pk=None):
"""Получить thumbnail документа.
Query параметры:
size: admin_thumbnail | small | medium | large (default: medium)
Returns:
Изображение thumbnail или 404 если документ не является изображением/PDF
"""
document = self.get_object()
if not document.file:
return Response(
{'error': 'У документа нет файла'},
status=status.HTTP_404_NOT_FOUND
)
size = request.query_params.get('size', 'medium')
allowed_sizes = ['admin_thumbnail', 'small', 'medium', 'large']
if size not in allowed_sizes:
return Response(
{'error': f'Размер должен быть одним из: {", ".join(allowed_sizes)}'},
status=status.HTTP_400_BAD_REQUEST
)
try:
# Получаем thumbnailer для файла
thumbnailer = get_thumbnailer(document.file.file)
# Генерируем thumbnail используя alias из settings
thumbnail = thumbnailer[size]
# Возвращаем файл
return FileResponse(
open(thumbnail.path, 'rb'),
content_type='image/jpeg'
)
except Exception as e:
return Response(
{'error': f'Не удалось создать thumbnail: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)
class FolderViewSet(ModelViewSet):
"""CRUD для папок документов (filer.Folder).
@ -613,3 +669,206 @@ class FolderViewSet(ModelViewSet):
)
return Response(serializer.data)
class DocumentTagViewSet(ModelViewSet):
"""CRUD для тегов документов.
Теги используются для категоризации документов.
"""
queryset = DocumentTag.objects.all()
serializer_class = DocumentTagSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
"""Возвращает теги, опционально с аннотацией количества документов."""
qs = super().get_queryset()
# Аннотируем количество документов для каждого тега
qs = qs.annotate(document_count=Count('documents'))
# Поиск по названию
search = self.request.query_params.get('search', '')
if search:
qs = qs.filter(
Q(name__icontains=search) |
Q(description__icontains=search)
)
return qs.order_by('name')
@action(detail=True, methods=['get'])
def documents(self, request, pk=None):
"""Получить все документы с этим тегом."""
tag = self.get_object()
documents = tag.documents.all()
serializer = DocumentReadSerializer(
documents,
many=True,
context={'request': request}
)
return Response(serializer.data)
class DocumentTypeViewSet(ModelViewSet):
"""CRUD для типов документов.
Типы определяют структуру метаданных и workflow для документов.
"""
queryset = DocumentType.objects.filter(is_active=True)
serializer_class = DocumentTypeSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
"""Возвращает типы документов.
Параметры:
?include_inactive=true - показать неактивные типы
"""
qs = super().get_queryset()
# Опционально показываем неактивные
include_inactive = self.request.query_params.get('include_inactive', '').lower() == 'true'
if include_inactive:
qs = DocumentType.objects.all()
# Поиск
search = self.request.query_params.get('search', '')
if search:
qs = qs.filter(
Q(name__icontains=search) |
Q(code__icontains=search) |
Q(description__icontains=search)
)
return qs.order_by('order', 'name')
@action(detail=True, methods=['get'])
def documents(self, request, pk=None):
"""Получить все документы этого типа."""
doc_type = self.get_object()
documents = Document.objects.filter(document_type=doc_type)
serializer = DocumentReadSerializer(
documents,
many=True,
context={'request': request}
)
return Response(serializer.data)
class CabinetViewSet(ModelViewSet):
"""CRUD для кабинетов (виртуальных коллекций документов).
Кабинеты позволяют организовать документы в виртуальные коллекции
независимо от физической структуры папок.
"""
queryset = Cabinet.objects.all()
serializer_class = CabinetSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
"""Возвращает кабинеты с поддержкой иерархии.
Параметры:
?parent_id=<id> - показать только дочерние кабинеты
?root=true - показать только корневые кабинеты
"""
qs = super().get_queryset()
# Аннотируем количество документов
qs = qs.annotate(document_count=Count('documents'))
parent_id = self.request.query_params.get('parent_id')
root = self.request.query_params.get('root', '').lower() == 'true'
if root:
qs = qs.filter(parent__isnull=True)
elif parent_id:
qs = qs.filter(parent_id=parent_id)
# Поиск
search = self.request.query_params.get('search', '')
if search:
qs = qs.filter(
Q(name__icontains=search) |
Q(description__icontains=search)
)
return qs.order_by('order', 'name')
@transaction.atomic
def perform_create(self, serializer):
"""Автоматически устанавливаем created_by при создании."""
serializer.save(created_by=self.request.user)
@action(detail=True, methods=['get'])
def children(self, request, pk=None):
"""Получить дочерние кабинеты."""
cabinet = self.get_object()
children = cabinet.children.all().order_by('order', 'name')
serializer = self.get_serializer(children, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'])
def documents(self, request, pk=None):
"""Получить документы в этом кабинете."""
cabinet = self.get_object()
documents = cabinet.documents.all()
serializer = DocumentReadSerializer(
documents,
many=True,
context={'request': request}
)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def add_document(self, request, pk=None):
"""Добавить документ в кабинет.
Body:
{"document_id": 123}
"""
cabinet = self.get_object()
document_id = request.data.get('document_id')
if not document_id:
return Response(
{'error': 'document_id обязателен'},
status=status.HTTP_400_BAD_REQUEST
)
try:
document = Document.objects.get(pk=document_id)
cabinet.documents.add(document)
return Response({'status': 'added'})
except Document.DoesNotExist:
return Response(
{'error': 'Документ не найден'},
status=status.HTTP_404_NOT_FOUND
)
@action(detail=True, methods=['post'])
def remove_document(self, request, pk=None):
"""Удалить документ из кабинета.
Body:
{"document_id": 123}
"""
cabinet = self.get_object()
document_id = request.data.get('document_id')
if not document_id:
return Response(
{'error': 'document_id обязателен'},
status=status.HTTP_400_BAD_REQUEST
)
try:
document = Document.objects.get(pk=document_id)
cabinet.documents.remove(document)
return Response({'status': 'removed'})
except Document.DoesNotExist:
return Response(
{'error': 'Документ не найден'},
status=status.HTTP_404_NOT_FOUND
)

View File

@ -12,7 +12,13 @@ from .schedule.views import (
# Communications ViewSets
from .communications.views import ChatViewSet, MessageViewSet, PollViewSet
from .documents.views import DocumentViewSet, FolderViewSet
from .documents.views import (
DocumentViewSet,
FolderViewSet,
DocumentTagViewSet,
DocumentTypeViewSet,
CabinetViewSet,
)
from .employees.views import (
DepartmentRoleViewSet,
DepartmentViewSet,
@ -42,6 +48,9 @@ router.register(r"schedule/relations", ScheduleEventRelationViewSet, basename="s
router.register(r"documents", DocumentViewSet, basename="documents")
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"requests", RequestViewSet, basename="request")