556 lines
19 KiB
Python
556 lines
19 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 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}"
|