Установлен и настроен 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
247 lines
8.8 KiB
Python
247 lines
8.8 KiB
Python
# documents/models.py
|
||
"""
|
||
Модели для управления документами с использованием django-filer.
|
||
|
||
Основные модели:
|
||
- Document: Документ с поддержкой filer для профессионального управления файлами
|
||
- DocumentAcknowledgement: Запись об ознакомлении сотрудника с документом
|
||
"""
|
||
|
||
from django.db import models
|
||
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'
|
||
|
||
|
||
@reversion.register() # Включаем версионирование для документов
|
||
class Document(models.Model):
|
||
"""
|
||
Документ с использованием django-filer для управления файлами и django-fsm для workflow.
|
||
|
||
Преимущества:
|
||
- Поддержка папок и вложенной структуры
|
||
- Автоматическая генерация thumbnails для изображений и PDF
|
||
- Полнотекстовый поиск по содержимому файлов
|
||
- Информация о размере, 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
|
||
file = FilerFileField(
|
||
verbose_name=_('Файл'),
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name='documents',
|
||
help_text=_('Файл, загруженный через django-filer')
|
||
)
|
||
|
||
description = models.TextField(_('Описание'), blank=True)
|
||
|
||
uploaded_by = models.ForeignKey(
|
||
User,
|
||
verbose_name=_('Загрузил'),
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='uploaded_documents'
|
||
)
|
||
uploaded_at = models.DateTimeField(_('Дата загрузки'), auto_now_add=True)
|
||
|
||
# Логика распространения документов
|
||
sent_to_all = models.BooleanField(
|
||
_('Разослать всем'),
|
||
default=True,
|
||
help_text=_('Если включено — уведомление получат все активные сотрудники')
|
||
)
|
||
departments = models.ManyToManyField(
|
||
'employees.Department',
|
||
verbose_name=_('Отделы-получатели'),
|
||
blank=True,
|
||
help_text=_('Документ будет доступен всем сотрудникам выбранных отделов (включая будущих)'),
|
||
related_name='documents'
|
||
)
|
||
recipients = models.ManyToManyField(
|
||
User,
|
||
verbose_name=_('Получатели'),
|
||
blank=True,
|
||
help_text=_('Если `Разослать всем` отключено, документ пойдет только этим'),
|
||
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 = _('Документы')
|
||
ordering = ['-uploaded_at']
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
@property
|
||
def file_size(self):
|
||
"""Размер файла в байтах (берется из filer)"""
|
||
if self.file:
|
||
return self.file.size
|
||
return 0
|
||
|
||
@property
|
||
def file_extension(self):
|
||
"""Расширение файла"""
|
||
if self.file:
|
||
return self.file.extension
|
||
return ''
|
||
|
||
def get_thumbnail(self, size='medium'):
|
||
"""
|
||
Получить thumbnail для файла (работает для изображений и PDF).
|
||
|
||
Args:
|
||
size: 'small' (200x200), 'medium' (400x400), 'large' (800x800)
|
||
|
||
Returns:
|
||
URL thumbnail или None
|
||
"""
|
||
if not self.file:
|
||
return None
|
||
|
||
try:
|
||
from easy_thumbnails.files import get_thumbnailer
|
||
thumbnailer = get_thumbnailer(self.file)
|
||
thumbnail = thumbnailer[size]
|
||
return thumbnail.url
|
||
except Exception:
|
||
# Если thumbnail не удалось создать (не изображение), возвращаем иконку по типу
|
||
return self.file.url
|
||
|
||
|
||
@reversion.register()
|
||
class DocumentAcknowledgement(models.Model):
|
||
"""
|
||
Фиксация, что сотрудник ознакомился с документом.
|
||
"""
|
||
document = models.ForeignKey(
|
||
Document,
|
||
verbose_name=_('Документ'),
|
||
on_delete=models.CASCADE,
|
||
related_name='acknowledgements'
|
||
)
|
||
user = models.ForeignKey(
|
||
User,
|
||
verbose_name=_('Сотрудник'),
|
||
on_delete=models.CASCADE,
|
||
related_name='document_acknowledgements'
|
||
)
|
||
acknowledged_at = models.DateTimeField(_('Время ознакомления'), auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = _('Ознакомление с документом')
|
||
verbose_name_plural = _('Ознакомления с документами')
|
||
unique_together = ('document', 'user')
|
||
ordering = ['-acknowledged_at']
|
||
|
||
def __str__(self):
|
||
return f'{self.user} — {self.document} @ {self.acknowledged_at:%d.%m.%Y %H:%M}'
|
||
|
||
|