Corp/backend/documents/models.py
igor_nadein abed82ed6c 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
2026-02-28 01:33:23 +03:00

247 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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}'