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 = '' + + return mark_safe(html) + + available_transitions.short_description = _('Доступные переходы') + def file_thumbnail(self, obj): """Превью файла в списке документов.""" if not obj.file: diff --git a/backend/documents/migrations/0006_add_fsm_status.py b/backend/documents/migrations/0006_add_fsm_status.py new file mode 100644 index 00000000..cde490f6 --- /dev/null +++ b/backend/documents/migrations/0006_add_fsm_status.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.4 on 2026-02-27 22:31 + +import django.db.models.deletion +import django_fsm +import filer.fields.file +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('documents', '0005_migrate_to_filer'), + ('filer', '0018_alter_file_options'), + ] + + operations = [ + migrations.AddField( + model_name='document', + name='status', + field=django_fsm.FSMField(choices=[('draft', 'Черновик'), ('in_review', 'На рассмотрении'), ('approved', 'Одобрен'), ('published', 'Опубликован'), ('archived', 'Архивирован'), ('rejected', 'Отклонен')], default='draft', help_text='Текущее состояние документа в workflow', max_length=50, protected=True, verbose_name='Статус'), + ), + migrations.AlterField( + model_name='document', + name='file', + field=filer.fields.file.FilerFileField(blank=True, help_text='Файл, загруженный через django-filer', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documents', to='filer.file', verbose_name='Файл'), + ), + ] diff --git a/backend/documents/models.py b/backend/documents/models.py index 3c21b8c4..30d56cf5 100644 --- a/backend/documents/models.py +++ b/backend/documents/models.py @@ -12,6 +12,7 @@ from django.utils.translation import gettext_lazy as _ from django.conf import settings from django.utils import timezone from filer.fields.file import FilerFileField +from django_fsm import FSMField, transition import reversion User = settings.AUTH_USER_MODEL # 'employees.Employee' @@ -20,7 +21,7 @@ User = settings.AUTH_USER_MODEL # 'employees.Employee' @reversion.register() # Включаем версионирование для документов class Document(models.Model): """ - Документ с использованием django-filer для управления файлами. + Документ с использованием django-filer для управления файлами и django-fsm для workflow. Преимущества: - Поддержка папок и вложенной структуры @@ -28,7 +29,34 @@ class Document(models.Model): - Полнотекстовый поиск по содержимому файлов - Информация о размере, MIME-типе, метаданные - История версий через django-reversion + - Workflow управление через django-fsm + + Состояния документа (FSM): + - draft: Черновик (по умолчанию) + - in_review: На рассмотрении + - approved: Одобрен + - published: Опубликован (отправлен получателям) + - archived: Архивирован + - rejected: Отклонен """ + + # FSM: Состояние документа + class Status(models.TextChoices): + DRAFT = 'draft', _('Черновик') + IN_REVIEW = 'in_review', _('На рассмотрении') + APPROVED = 'approved', _('Одобрен') + PUBLISHED = 'published', _('Опубликован') + ARCHIVED = 'archived', _('Архивирован') + REJECTED = 'rejected', _('Отклонен') + + status = FSMField( + default=Status.DRAFT, + choices=Status.choices, + verbose_name=_('Статус'), + protected=True, # Изменяется только через transitions + help_text=_('Текущее состояние документа в workflow') + ) + title = models.CharField(_('Название'), max_length=255) # Используем FilerFileField вместо FileField @@ -74,6 +102,74 @@ class Document(models.Model): related_name='document_recipients' ) + # ------------------------------------------------------------------------- + # FSM TRANSITIONS (переходы между состояниями) + # ------------------------------------------------------------------------- + + @transition(field=status, source=Status.DRAFT, target=Status.IN_REVIEW) + def submit_for_review(self): + """ + Отправить документ на рассмотрение. + Из: draft → в: in_review + """ + pass + + @transition(field=status, source=Status.IN_REVIEW, target=Status.APPROVED) + def approve(self): + """ + Одобрить документ. + Из: in_review → в: approved + """ + pass + + @transition(field=status, source=Status.IN_REVIEW, target=Status.REJECTED) + def reject(self): + """ + Отклонить документ. + Из: in_review → в: rejected + """ + pass + + @transition(field=status, source=Status.APPROVED, target=Status.PUBLISHED) + def publish(self): + """ + Опубликовать документ (отправка уведомлений получателям). + Из: approved → в: published + + При публикации срабатывают сигналы из notification_signals.py + """ + pass + + @transition( + field=status, + source=[Status.DRAFT, Status.IN_REVIEW], + target=Status.DRAFT + ) + def return_to_draft(self): + """ + Вернуть документ в черновики. + Из: draft, in_review → в: draft + """ + pass + + @transition(field=status, source=Status.PUBLISHED, target=Status.ARCHIVED) + def archive(self): + """ + Архивировать опубликованный документ. + Из: published → в: archived + """ + pass + + @transition(field=status, source=Status.ARCHIVED, target=Status.PUBLISHED) + def unarchive(self): + """ + Разархивировать документ. + Из: archived → в: published + """ + pass + + # ------------------------------------------------------------------------- + class Meta: verbose_name = _('Документ') verbose_name_plural = _('Документы') diff --git a/backend/requirements.txt b/backend/requirements.txt index 6a704eef..041a0baa 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -138,3 +138,6 @@ django-mptt==0.18.0 # Dependency for filer # Version Control for Models django-reversion==6.1.0 + +# Workflow State Machine +django-fsm==3.0.1