diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index 738cf90f..cf21dfd2 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -120,6 +120,8 @@ class DocumentReadSerializer(serializers.ModelSerializer): departments = DepartmentBriefSerializer(many=True, read_only=True) file_url = FilerFileSerializerField(source="file", read_only=True) is_acknowledged = serializers.SerializerMethodField() + status = serializers.CharField(source='get_status_display', read_only=True) + status_code = serializers.CharField(source='status', read_only=True) class Meta: model = Document @@ -127,6 +129,8 @@ class DocumentReadSerializer(serializers.ModelSerializer): "id", "title", "description", + "status", # Человекочитаемый статус + "status_code", # Код статуса для программной обработки "uploaded_by", "uploaded_at", "sent_to_all", diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index 9f90f457..cc54ea4a 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -210,3 +210,113 @@ class DocumentViewSet(ModelViewSet): }, } ) + + # ------------------------------------------------------------------------- + # FSM WORKFLOW ACTIONS + # ------------------------------------------------------------------------- + + @action(detail=True, methods=['post'], url_path='submit-for-review') + def submit_for_review(self, request, pk=None): + """Отправить документ на рассмотрение (draft → in_review).""" + document = self.get_object() + try: + document.submit_for_review() + document.save() + serializer = self.get_serializer(document) + return Response(serializer.data) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + @action(detail=True, methods=['post']) + def approve(self, request, pk=None): + """Одобрить документ (in_review → approved).""" + document = self.get_object() + try: + document.approve() + document.save() + serializer = self.get_serializer(document) + return Response(serializer.data) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + @action(detail=True, methods=['post']) + def reject(self, request, pk=None): + """Отклонить документ (in_review → rejected).""" + document = self.get_object() + try: + document.reject() + document.save() + serializer = self.get_serializer(document) + return Response(serializer.data) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + @action(detail=True, methods=['post']) + def publish(self, request, pk=None): + """Опубликовать документ (approved → published).""" + document = self.get_object() + try: + document.publish() + document.save() + serializer = self.get_serializer(document) + return Response(serializer.data) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + @action(detail=True, methods=['post'], url_path='return-to-draft') + def return_to_draft(self, request, pk=None): + """Вернуть документ в черновики.""" + document = self.get_object() + try: + document.return_to_draft() + document.save() + serializer = self.get_serializer(document) + return Response(serializer.data) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + @action(detail=True, methods=['post']) + def archive(self, request, pk=None): + """Архивировать документ (published → archived).""" + document = self.get_object() + try: + document.archive() + document.save() + serializer = self.get_serializer(document) + return Response(serializer.data) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + @action(detail=True, methods=['post']) + def unarchive(self, request, pk=None): + """Разархивировать документ (archived → published).""" + document = self.get_object() + try: + document.unarchive() + document.save() + serializer = self.get_serializer(document) + return Response(serializer.data) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + diff --git a/backend/documents/admin.py b/backend/documents/admin.py index ff3bbd3e..143e9f0e 100644 --- a/backend/documents/admin.py +++ b/backend/documents/admin.py @@ -8,12 +8,14 @@ Django admin для моделей Document и DocumentAcknowledgement. - Превью thumbnails в списке и форме редактирования - Полнотекстовый поиск по содержимому файлов - Версионирование через django-reversion +- Workflow управление через django-fsm """ from django.contrib import admin from django.utils.translation import gettext_lazy as _ from django.utils.html import format_html, mark_safe from django.urls import reverse +from django_fsm import get_available_FIELD_transitions from .models import Document, DocumentAcknowledgement from django.contrib.auth import get_user_model @@ -46,25 +48,27 @@ class AcknowledgementInline(admin.TabularInline): @admin.register(Document) class DocumentAdmin(admin.ModelAdmin): """ - Admin для Document с поддержкой django-filer. + Admin для Document с поддержкой django-filer и django-fsm. Возможности: - Drag & drop загрузка файлов через filer - Превью thumbnails для изображений и PDF - Поиск по содержимому файлов - Версионирование через django-reversion + - Workflow управление через django-fsm """ inlines = [AcknowledgementInline] list_display = ( 'title', + 'status_badge', # FSM статус с цветовым индикатором 'file_thumbnail', 'file_info', 'uploaded_at', 'sent_to_all', 'acknowledgement_status', ) - list_filter = ('sent_to_all', 'uploaded_at', 'departments') + list_filter = ('status', 'sent_to_all', 'uploaded_at', 'departments') search_fields = ('title', 'description', 'file__name', 'file__description') filter_horizontal = ('recipients', 'departments') actions = ['send_document'] @@ -75,7 +79,8 @@ class DocumentAdmin(admin.ModelAdmin): fieldsets = ( (None, { 'fields': ( - 'title', + 'title', + 'status', # FSM статус 'file', # FilerFileField с drag & drop 'file_preview', # Превью файла 'description', @@ -84,6 +89,10 @@ class DocumentAdmin(admin.ModelAdmin): 'recipients' ), }), + (_('Workflow'), { + 'fields': ('available_transitions',), + 'classes': ('collapse',), + }), (_('Meta'), { 'fields': ('uploaded_by', 'uploaded_at', 'file_size', 'file_extension'), 'classes': ('collapse',), @@ -93,6 +102,8 @@ class DocumentAdmin(admin.ModelAdmin): }), ) readonly_fields = ( + 'status', # FSM поле только через transitions + 'available_transitions', 'uploaded_by', 'uploaded_at', 'file_preview', @@ -108,6 +119,43 @@ class DocumentAdmin(admin.ModelAdmin): obj.uploaded_by = request.user super().save_model(request, obj, form, change) + def status_badge(self, obj): + """Статус документа с цветовым индикатором.""" + colors = { + 'draft': '#6c757d', # серый + 'in_review': '#0dcaf0', # голубой + 'approved': '#198754', # зеленый + 'published': '#0d6efd', # синий + 'archived': '#6c757d', # серый + 'rejected': '#dc3545', # красный + } + color = colors.get(obj.status, '#6c757d') + return format_html( + '{}', + color, + obj.get_status_display() + ) + status_badge.short_description = _('Статус') + + def available_transitions(self, obj): + """Список доступных переходов (transitions) для текущего состояния.""" + if not obj.pk: + return '—' + + transitions = get_available_FIELD_transitions(obj, Document.status) + if not transitions: + return format_html('Нет доступных переходов') + + html = '