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:
parent
f138358862
commit
abed82ed6c
@ -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",
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
27
backend/documents/migrations/0006_add_fsm_status.py
Normal file
27
backend/documents/migrations/0006_add_fsm_status.py
Normal 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='Файл'),
|
||||
),
|
||||
]
|
||||
@ -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 = _('Документы')
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user