- Добавлены расширенные модели для Mayan-level DMS: * DocumentType - типы документов с JSON schema * DocumentTag - теги для категоризации * Cabinet - виртуальные коллекции * DocumentMetadata - динамические key-value метаданные * DocumentAuditLog - полный аудит действий - Расширена модель Document: * extracted_text - для полнотекстового поиска * document_type - FK к типу * modified_by, modified_at - audit trail - Интегрирован django-watson для full-text search: * Регистрация с extracted_text * Кастомный adapter с URL * Обновлен индекс (22 документа) - API Serializers: * DocumentTypeSerializer * DocumentTagSerializer * CabinetSerializer * FolderSerializer расширен * DocumentReadSerializer обновлен с новыми полями Следующий этап: Phase 2 Frontend Foundation
576 lines
20 KiB
Python
576 lines
20 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')
|
||
)
|
||
|
||
# Папка для организации документов (иерархическая структура)
|
||
folder = models.ForeignKey(
|
||
'filer.Folder',
|
||
verbose_name=_('Папка'),
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name='documents',
|
||
help_text=_('Папка для организации документов в иерархической структуре')
|
||
)
|
||
|
||
description = models.TextField(_('Описание'), blank=True)
|
||
|
||
# Тип документа (Договор, Приказ, Акт и т.д.)
|
||
document_type = models.ForeignKey(
|
||
'DocumentType',
|
||
verbose_name=_('Тип документа'),
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name='documents',
|
||
help_text=_('Тип документа определяет шаблон метаданных и workflow')
|
||
)
|
||
|
||
# Full-text search - извлеченный текст (OCR или парсинг PDF/DOCX)
|
||
extracted_text = models.TextField(
|
||
_('Извлеченный текст'),
|
||
blank=True,
|
||
editable=False,
|
||
help_text=_('Текст извлечен из файла для полнотекстового поиска')
|
||
)
|
||
|
||
# Audit trail
|
||
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)
|
||
|
||
modified_by = models.ForeignKey(
|
||
User,
|
||
verbose_name=_('Последнее изменение'),
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='modified_documents'
|
||
)
|
||
modified_at = models.DateTimeField(_('Дата изменения'), auto_now=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
|
||
|
||
@property
|
||
def folder_path(self):
|
||
"""
|
||
Полный путь папки документа (breadcrumbs).
|
||
|
||
Returns:
|
||
str: Путь вида "Корень / Папка1 / Папка2" или пустая строка
|
||
"""
|
||
if not self.folder:
|
||
return ''
|
||
|
||
# Собираем путь от корня до текущей папки
|
||
path_parts = []
|
||
current = self.folder
|
||
while current:
|
||
path_parts.insert(0, current.name)
|
||
current = current.parent
|
||
|
||
return ' / '.join(path_parts)
|
||
|
||
def get_folder_hierarchy(self):
|
||
"""
|
||
Получить иерархию папок от корня до текущей.
|
||
|
||
Returns:
|
||
list: Список словарей [{id, name}, ...] от корня к текущей папке
|
||
"""
|
||
if not self.folder:
|
||
return []
|
||
|
||
hierarchy = []
|
||
current = self.folder
|
||
while current:
|
||
hierarchy.insert(0, {'id': current.id, 'name': current.name})
|
||
current = current.parent
|
||
|
||
return hierarchy
|
||
|
||
|
||
@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}'
|
||
|
||
|
||
# ============================================================================
|
||
# ТИПЫ ДОКУМЕНТОВ И МЕТАДАННЫЕ
|
||
# ============================================================================
|
||
|
||
class DocumentType(models.Model):
|
||
"""
|
||
Типы документов (Договор, Приказ, Акт, Счет и т.д.).
|
||
Определяет структуру метаданных и workflow для документов этого типа.
|
||
"""
|
||
name = models.CharField(_('Название'), max_length=100)
|
||
code = models.SlugField(_('Код'), unique=True, max_length=50)
|
||
description = models.TextField(_('Описание'), blank=True)
|
||
|
||
# JSON Schema для валидации метаданных этого типа
|
||
metadata_schema = models.JSONField(
|
||
_('Схема метаданных'),
|
||
null=True,
|
||
blank=True,
|
||
help_text=_('JSON Schema для валидации метаданных документов этого типа')
|
||
)
|
||
|
||
# Иконка для UI
|
||
icon = models.CharField(
|
||
_('Иконка'),
|
||
max_length=50,
|
||
default='file-earmark-text',
|
||
help_text=_('Bootstrap Icons класс')
|
||
)
|
||
|
||
# Цвет для UI
|
||
color = models.CharField(
|
||
_('Цвет'),
|
||
max_length=7,
|
||
default='#0d6efd',
|
||
help_text=_('HEX цвет для UI')
|
||
)
|
||
|
||
is_active = models.BooleanField(_('Активен'), default=True)
|
||
order = models.IntegerField(_('Порядок'), default=0)
|
||
|
||
class Meta:
|
||
verbose_name = _('Тип документа')
|
||
verbose_name_plural = _('Типы документов')
|
||
ordering = ['order', 'name']
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class DocumentMetadata(models.Model):
|
||
"""
|
||
Динамические метаданные документа (key-value пары).
|
||
Позволяет добавлять произвольные поля к документам.
|
||
"""
|
||
document = models.ForeignKey(
|
||
Document,
|
||
on_delete=models.CASCADE,
|
||
related_name='metadata_entries',
|
||
verbose_name=_('Документ')
|
||
)
|
||
key = models.CharField(_('Ключ'), max_length=100)
|
||
value = models.TextField(_('Значение'))
|
||
|
||
# Тип значения для правильного отображения и валидации
|
||
class ValueType(models.TextChoices):
|
||
TEXT = 'text', _('Текст')
|
||
NUMBER = 'number', _('Число')
|
||
DATE = 'date', _('Дата')
|
||
BOOLEAN = 'boolean', _('Да/Нет')
|
||
URL = 'url', _('Ссылка')
|
||
|
||
value_type = models.CharField(
|
||
_('Тип значения'),
|
||
max_length=20,
|
||
choices=ValueType.choices,
|
||
default=ValueType.TEXT
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = _('Метаданные документа')
|
||
verbose_name_plural = _('Метаданные документов')
|
||
unique_together = ('document', 'key')
|
||
ordering = ['key']
|
||
|
||
def __str__(self):
|
||
return f'{self.key}: {self.value}'
|
||
|
||
|
||
class DocumentTag(models.Model):
|
||
"""
|
||
Теги для категоризации документов.
|
||
Аналог Mayan EDMS tags.
|
||
"""
|
||
name = models.CharField(_('Название'), max_length=100, unique=True)
|
||
slug = models.SlugField(_('Слаг'), unique=True)
|
||
color = models.CharField(
|
||
_('Цвет'),
|
||
max_length=7,
|
||
default='#0d6efd',
|
||
help_text=_('HEX цвет для UI')
|
||
)
|
||
description = models.TextField(_('Описание'), blank=True)
|
||
|
||
documents = models.ManyToManyField(
|
||
Document,
|
||
related_name='tags',
|
||
blank=True,
|
||
verbose_name=_('Документы')
|
||
)
|
||
|
||
created_at = models.DateTimeField(_('Создан'), auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = _('Тег')
|
||
verbose_name_plural = _('Теги')
|
||
ordering = ['name']
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class Cabinet(models.Model):
|
||
"""
|
||
Кабинеты - виртуальные хранилища документов.
|
||
Аналог Mayan EDMS cabinets. Позволяет организовать документы
|
||
в виртуальные коллекции независимо от физической структуры папок.
|
||
"""
|
||
name = models.CharField(_('Название'), max_length=200)
|
||
slug = models.SlugField(_('Слаг'), unique=True)
|
||
description = models.TextField(_('Описание'), blank=True)
|
||
|
||
# Иерархическая структура кабинетов
|
||
parent = models.ForeignKey(
|
||
'self',
|
||
verbose_name=_('Родительский кабинет'),
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.CASCADE,
|
||
related_name='children'
|
||
)
|
||
|
||
documents = models.ManyToManyField(
|
||
Document,
|
||
related_name='cabinets',
|
||
blank=True,
|
||
verbose_name=_('Документы')
|
||
)
|
||
|
||
# Иконка и цвет для UI
|
||
icon = models.CharField(
|
||
_('Иконка'),
|
||
max_length=50,
|
||
default='folder',
|
||
help_text=_('Bootstrap Icons класс')
|
||
)
|
||
color = models.CharField(
|
||
_('Цвет'),
|
||
max_length=7,
|
||
default='#6c757d',
|
||
help_text=_('HEX цвет для UI')
|
||
)
|
||
|
||
created_by = models.ForeignKey(
|
||
User,
|
||
verbose_name=_('Создал'),
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
related_name='created_cabinets'
|
||
)
|
||
created_at = models.DateTimeField(_('Создан'), auto_now_add=True)
|
||
|
||
order = models.IntegerField(_('Порядок'), default=0)
|
||
|
||
class Meta:
|
||
verbose_name = _('Кабинет')
|
||
verbose_name_plural = _('Кабинеты')
|
||
ordering = ['order', 'name']
|
||
|
||
def __str__(self):
|
||
if self.parent:
|
||
return f'{self.parent.name} / {self.name}'
|
||
return self.name
|
||
|
||
|
||
# ============================================================================
|
||
# АУДИТ И ИСТОРИЯ
|
||
# ============================================================================
|
||
|
||
class DocumentAuditLog(models.Model):
|
||
"""
|
||
Полный аудит всех действий с документами.
|
||
Фиксирует кто, когда и что сделал с документом.
|
||
"""
|
||
document = models.ForeignKey(
|
||
Document,
|
||
on_delete=models.CASCADE,
|
||
related_name='audit_log',
|
||
verbose_name=_('Документ')
|
||
)
|
||
user = models.ForeignKey(
|
||
User,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
verbose_name=_('Пользователь')
|
||
)
|
||
|
||
class Action(models.TextChoices):
|
||
CREATED = 'created', _('Создан')
|
||
VIEWED = 'viewed', _('Просмотрен')
|
||
DOWNLOADED = 'downloaded', _('Скачан')
|
||
EDITED = 'edited', _('Изменен')
|
||
MOVED = 'moved', _('Перемещен')
|
||
DELETED = 'deleted', _('Удален')
|
||
STATUS_CHANGED = 'status_changed', _('Изменен статус')
|
||
PERMISSIONS_CHANGED = 'permissions_changed', _('Изменены права')
|
||
SIGNED = 'signed', _('Подписан')
|
||
|
||
action = models.CharField(
|
||
_('Действие'),
|
||
max_length=50,
|
||
choices=Action.choices
|
||
)
|
||
timestamp = models.DateTimeField(_('Время'), auto_now_add=True)
|
||
ip_address = models.GenericIPAddressField(
|
||
_('IP адрес'),
|
||
null=True,
|
||
blank=True
|
||
)
|
||
user_agent = models.TextField(_('User Agent'), blank=True)
|
||
|
||
# Дополнительные данные о действии (JSON)
|
||
metadata = models.JSONField(
|
||
_('Метаданные'),
|
||
null=True,
|
||
blank=True,
|
||
help_text=_('Дополнительная информация о действии')
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = _('Запись аудита')
|
||
verbose_name_plural = _('Записи аудита')
|
||
ordering = ['-timestamp']
|
||
indexes = [
|
||
models.Index(fields=['document', '-timestamp']),
|
||
models.Index(fields=['user', '-timestamp']),
|
||
models.Index(fields=['action', '-timestamp']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f'{self.user} {self.get_action_display()} {self.document} @ {self.timestamp:%d.%m.%Y %H:%M}'
|