Corp/backend/documents/models.py

556 lines
19 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 filer.fields.file import FilerFileField
import reversion
User = settings.AUTH_USER_MODEL # 'employees.Employee'
@reversion.register() # Включаем версионирование для документов
class Document(models.Model):
"""
Документ с использованием django-filer для управления файлами.
Преимущества:
- Поддержка папок и вложенной структуры
- Автоматическая генерация thumbnails для изображений и PDF
- Полнотекстовый поиск по содержимому файлов
- Информация о размере, MIME-типе, метаданные
- История версий через django-reversion
"""
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)
# 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=_(
"Если включено — уведомление получат все активные сотрудники"
),
)
# Требование ознакомления
acknowledgement_required = models.BooleanField(
_("Требуется ознакомление"),
default=True,
help_text=_(
"Если включено — сотрудники должны подтвердить ознакомление"
),
)
acknowledgement_for_all = models.BooleanField(
_("Ознакомление для всех с доступом"),
default=True,
help_text=_(
"Если включено — ознакомиться должны все сотрудники, "
"которым доступен документ"
),
)
is_regulation = models.BooleanField(
_("Регламент"),
default=False,
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",
)
acknowledgement_departments = models.ManyToManyField(
"employees.Department",
verbose_name=_("Отделы для ознакомления"),
blank=True,
related_name="documents_requiring_acknowledgement",
help_text=_(
"Сотрудники выбранных отделов должны ознакомиться с документом"
),
)
acknowledgement_recipients = models.ManyToManyField(
User,
verbose_name=_("Сотрудники для ознакомления"),
blank=True,
related_name="documents_requiring_acknowledgement",
help_text=_(
"Выбранные сотрудники должны ознакомиться с документом"
),
)
# Связанные документы
related_documents = models.ManyToManyField(
"self",
verbose_name=_("Связанные документы"),
blank=True,
symmetrical=True,
help_text=_("Документы, связанные с этим"),
)
# -------------------------------------------------------------------------
# ПУБЛИКАЦИЯ ДОКУМЕНТА
# -------------------------------------------------------------------------
class Meta:
verbose_name = _("Документ")
verbose_name_plural = _("Документы")
ordering = ["-uploaded_at"]
def __str__(self):
return self.title
@property
def created_by(self):
"""Алиас для uploaded_by (кто создал документ)."""
return self.uploaded_by
@property
def created_at(self):
"""Алиас для uploaded_at (когда создан документ)."""
return self.uploaded_at
@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} @ "
f"{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}"