Corp/backend/documents/models.py
igor_nadein ab07a6ff9a feat(documents): Phase 1 - Backend Foundation Complete
- Добавлены расширенные модели для 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
2026-02-28 09:26:23 +03:00

576 lines
20 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')
)
# Папка для организации документов (иерархическая структура)
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}'