feat(documents): Добавлен django-fsm для workflow управления

Установлен и настроен django-fsm==3.0.1 для управления жизненным циклом документов.

Изменения в модели (documents/models.py):
- Добавлено FSM поле 'status' с 6 состояниями:
  * draft (черновик) - по умолчанию
  * in_review (на рассмотрении)
  * approved (одобрен)
  * published (опубликован)
  * archived (архивирован)
  * rejected (отклонен)

- Созданы transitions (переходы между состояниями):
  * submit_for_review(): draft → in_review
  * approve(): in_review → approved
  * reject(): in_review → rejected
  * publish(): approved → published (с отправкой уведомлений)
  * return_to_draft(): draft/in_review → draft
  * archive(): published → archived
  * unarchive(): archived → published

Изменения в админке (documents/admin.py):
- Добавлен status_badge() с цветовыми индикаторами статусов
- Добавлен available_transitions() - список доступных переходов
- Поле status добавлено в list_display и fieldsets
- Фильтрация по статусам в list_filter

Изменения в API (api/v1/documents/):
- Добавлены поля status и status_code в DocumentReadSerializer
- Созданы actions для всех FSM transitions:
  * POST /api/v1/documents/{id}/submit-for-review/
  * POST /api/v1/documents/{id}/approve/
  * POST /api/v1/documents/{id}/reject/
  * POST /api/v1/documents/{id}/publish/
  * POST /api/v1/documents/{id}/return-to-draft/
  * POST /api/v1/documents/{id}/archive/
  * POST /api/v1/documents/{id}/unarchive/

Миграция:
- Создана 0006_add_fsm_status.py
- Применена успешно
- Все 16 юнит-тестов проходят

Зависимости:
- Добавлен django-fsm==3.0.1 в requirements.txt
This commit is contained in:
igor_nadein 2026-02-28 01:33:23 +03:00
parent f138358862
commit abed82ed6c
6 changed files with 292 additions and 4 deletions

View File

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

View File

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

View File

@ -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(
'<span style="background-color:{}; color:white; padding:3px 8px; '
'border-radius:3px; font-size:11px; font-weight:bold;">{}</span>',
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('<em>Нет доступных переходов</em>')
html = '<ul style="margin:0; padding-left:20px;">'
for transition in transitions:
html += f'<li><strong>{transition.name}</strong>: {transition.target}</li>'
html += '</ul>'
return mark_safe(html)
available_transitions.short_description = _('Доступные переходы')
def file_thumbnail(self, obj):
"""Превью файла в списке документов."""
if not obj.file:

View File

@ -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='Файл'),
),
]

View File

@ -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 = _('Документы')

View File

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