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
This commit is contained in:
parent
856e08db4a
commit
ab07a6ff9a
@ -109,6 +109,72 @@ class DepartmentBriefSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(read_only=True)
|
||||
|
||||
|
||||
class FolderSerializer(serializers.Serializer):
|
||||
"""Сериализатор для папки filer.Folder."""
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
name = serializers.CharField()
|
||||
parent_id = serializers.IntegerField(source='parent.id', read_only=True, allow_null=True)
|
||||
path = serializers.SerializerMethodField()
|
||||
|
||||
def get_path(self, obj) -> str:
|
||||
"""Возвращает полный путь папки."""
|
||||
path_parts = []
|
||||
current = obj
|
||||
while current:
|
||||
path_parts.insert(0, current.name)
|
||||
current = current.parent
|
||||
return ' / '.join(path_parts)
|
||||
|
||||
|
||||
class FolderBriefSerializer(serializers.Serializer):
|
||||
"""Краткий сериализатор папки для вложенного отображения."""
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
name = serializers.CharField(read_only=True)
|
||||
|
||||
|
||||
class DocumentTypeSerializer(serializers.Serializer):
|
||||
"""Сериализатор для типов документов."""
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
name = serializers.CharField()
|
||||
code = serializers.SlugField()
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
icon = serializers.CharField(required=False)
|
||||
color = serializers.CharField(required=False)
|
||||
is_active = serializers.BooleanField(default=True)
|
||||
|
||||
|
||||
class DocumentTagSerializer(serializers.Serializer):
|
||||
"""Сериализатор для тегов документов."""
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
name = serializers.CharField()
|
||||
slug = serializers.SlugField(required=False)
|
||||
color = serializers.CharField(required=False)
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
document_count = serializers.SerializerMethodField()
|
||||
|
||||
def get_document_count(self, obj) -> int:
|
||||
"""Возвращает количество документов с этим тегом."""
|
||||
return obj.documents.count() if hasattr(obj, 'documents') else 0
|
||||
|
||||
|
||||
class CabinetSerializer(serializers.Serializer):
|
||||
"""Сериализатор для кабинетов (виртуальных коллекций)."""
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
name = serializers.CharField()
|
||||
slug = serializers.SlugField(required=False)
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
parent_id = serializers.IntegerField(source='parent.id', required=False, allow_null=True)
|
||||
icon = serializers.CharField(required=False)
|
||||
color = serializers.CharField(required=False)
|
||||
document_count = serializers.SerializerMethodField()
|
||||
created_by = EmployeeBriefSerializer(read_only=True)
|
||||
created_at = serializers.DateTimeField(read_only=True)
|
||||
|
||||
def get_document_count(self, obj) -> int:
|
||||
"""Возвращает количество документов в кабинете."""
|
||||
return obj.documents.count() if hasattr(obj, 'documents') else 0
|
||||
|
||||
|
||||
class DocumentReadSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор для чтения документа.
|
||||
|
||||
@ -116,9 +182,16 @@ class DocumentReadSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
||||
uploaded_by = EmployeeBriefSerializer(read_only=True)
|
||||
modified_by = EmployeeBriefSerializer(read_only=True)
|
||||
recipients = EmployeeBriefSerializer(many=True, read_only=True)
|
||||
departments = DepartmentBriefSerializer(many=True, read_only=True)
|
||||
folder = FolderBriefSerializer(read_only=True)
|
||||
folder_path = serializers.CharField(read_only=True)
|
||||
document_type = DocumentTypeSerializer(read_only=True)
|
||||
tags = DocumentTagSerializer(many=True, read_only=True)
|
||||
file_url = FilerFileSerializerField(source="file", read_only=True)
|
||||
file_name = serializers.CharField(source='file.name', read_only=True, allow_null=True)
|
||||
file_size = serializers.IntegerField(source='file.size', read_only=True, allow_null=True)
|
||||
is_acknowledged = serializers.SerializerMethodField()
|
||||
status = serializers.CharField(source='get_status_display', read_only=True)
|
||||
status_code = serializers.CharField(source='status', read_only=True)
|
||||
@ -129,14 +202,23 @@ class DocumentReadSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"extracted_text", # Для полнотекстового поиска
|
||||
"folder", # Папка документа
|
||||
"folder_path", # Полный путь папки
|
||||
"document_type", # Тип документа
|
||||
"tags", # Теги
|
||||
"status", # Человекочитаемый статус
|
||||
"status_code", # Код статуса для программной обработки
|
||||
"uploaded_by",
|
||||
"uploaded_at",
|
||||
"modified_by",
|
||||
"modified_at",
|
||||
"sent_to_all",
|
||||
"departments",
|
||||
"recipients",
|
||||
"file_url",
|
||||
"file_name",
|
||||
"file_size",
|
||||
"is_acknowledged",
|
||||
)
|
||||
|
||||
|
||||
20
backend/documents/migrations/0007_add_folder_support.py
Normal file
20
backend/documents/migrations/0007_add_folder_support.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.4 on 2026-02-28 05:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('documents', '0006_add_fsm_status'),
|
||||
('filer', '0018_alter_file_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='folder',
|
||||
field=models.ForeignKey(blank=True, help_text='Папка для организации документов в иерархической структуре', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documents', to='filer.folder', verbose_name='Папка'),
|
||||
),
|
||||
]
|
||||
128
backend/documents/migrations/0008_add_extended_models.py
Normal file
128
backend/documents/migrations/0008_add_extended_models.py
Normal file
@ -0,0 +1,128 @@
|
||||
# Generated by Django 5.2.4 on 2026-02-28 06:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('documents', '0007_add_folder_support'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DocumentType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Название')),
|
||||
('code', models.SlugField(unique=True, verbose_name='Код')),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('metadata_schema', models.JSONField(blank=True, help_text='JSON Schema для валидации метаданных документов этого типа', null=True, verbose_name='Схема метаданных')),
|
||||
('icon', models.CharField(default='file-earmark-text', help_text='Bootstrap Icons класс', max_length=50, verbose_name='Иконка')),
|
||||
('color', models.CharField(default='#0d6efd', help_text='HEX цвет для UI', max_length=7, verbose_name='Цвет')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
|
||||
('order', models.IntegerField(default=0, verbose_name='Порядок')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тип документа',
|
||||
'verbose_name_plural': 'Типы документов',
|
||||
'ordering': ['order', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='extracted_text',
|
||||
field=models.TextField(blank=True, editable=False, help_text='Текст извлечен из файла для полнотекстового поиска', verbose_name='Извлеченный текст'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='modified_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Дата изменения'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='modified_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_documents', to=settings.AUTH_USER_MODEL, verbose_name='Последнее изменение'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Cabinet',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Название')),
|
||||
('slug', models.SlugField(unique=True, verbose_name='Слаг')),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('icon', models.CharField(default='folder', help_text='Bootstrap Icons класс', max_length=50, verbose_name='Иконка')),
|
||||
('color', models.CharField(default='#6c757d', help_text='HEX цвет для UI', max_length=7, verbose_name='Цвет')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
|
||||
('order', models.IntegerField(default=0, verbose_name='Порядок')),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_cabinets', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
|
||||
('documents', models.ManyToManyField(blank=True, related_name='cabinets', to='documents.document', verbose_name='Документы')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='documents.cabinet', verbose_name='Родительский кабинет')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Кабинет',
|
||||
'verbose_name_plural': 'Кабинеты',
|
||||
'ordering': ['order', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentTag',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='Название')),
|
||||
('slug', models.SlugField(unique=True, verbose_name='Слаг')),
|
||||
('color', models.CharField(default='#0d6efd', help_text='HEX цвет для UI', max_length=7, verbose_name='Цвет')),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
|
||||
('documents', models.ManyToManyField(blank=True, related_name='tags', to='documents.document', verbose_name='Документы')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тег',
|
||||
'verbose_name_plural': 'Теги',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='document_type',
|
||||
field=models.ForeignKey(blank=True, help_text='Тип документа определяет шаблон метаданных и workflow', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documents', to='documents.documenttype', verbose_name='Тип документа'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentAuditLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action', models.CharField(choices=[('created', 'Создан'), ('viewed', 'Просмотрен'), ('downloaded', 'Скачан'), ('edited', 'Изменен'), ('moved', 'Перемещен'), ('deleted', 'Удален'), ('status_changed', 'Изменен статус'), ('permissions_changed', 'Изменены права'), ('signed', 'Подписан')], max_length=50, verbose_name='Действие')),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Время')),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP адрес')),
|
||||
('user_agent', models.TextField(blank=True, verbose_name='User Agent')),
|
||||
('metadata', models.JSONField(blank=True, help_text='Дополнительная информация о действии', null=True, verbose_name='Метаданные')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='audit_log', to='documents.document', verbose_name='Документ')),
|
||||
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Запись аудита',
|
||||
'verbose_name_plural': 'Записи аудита',
|
||||
'ordering': ['-timestamp'],
|
||||
'indexes': [models.Index(fields=['document', '-timestamp'], name='documents_d_documen_5c0383_idx'), models.Index(fields=['user', '-timestamp'], name='documents_d_user_id_146cf5_idx'), models.Index(fields=['action', '-timestamp'], name='documents_d_action_c6b54b_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentMetadata',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=100, verbose_name='Ключ')),
|
||||
('value', models.TextField(verbose_name='Значение')),
|
||||
('value_type', models.CharField(choices=[('text', 'Текст'), ('number', 'Число'), ('date', 'Дата'), ('boolean', 'Да/Нет'), ('url', 'Ссылка')], default='text', max_length=20, verbose_name='Тип значения')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='metadata_entries', to='documents.document', verbose_name='Документ')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Метаданные документа',
|
||||
'verbose_name_plural': 'Метаданные документов',
|
||||
'ordering': ['key'],
|
||||
'unique_together': {('document', 'key')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -69,8 +69,39 @@ class Document(models.Model):
|
||||
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=_('Загрузил'),
|
||||
@ -80,6 +111,16 @@ class Document(models.Model):
|
||||
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(
|
||||
@ -213,6 +254,44 @@ class Document(models.Model):
|
||||
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()
|
||||
@ -244,3 +323,253 @@ class DocumentAcknowledgement(models.Model):
|
||||
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}'
|
||||
|
||||
@ -140,10 +140,30 @@ if Equipment:
|
||||
# 11. Документы (Documents)
|
||||
Document = get_model_safe("documents", "Document")
|
||||
if Document:
|
||||
from watson.search import SearchAdapter
|
||||
|
||||
class DocumentAdapter(SearchAdapter):
|
||||
"""Кастомный адаптер для Document с URL и расширенным поиском."""
|
||||
|
||||
def get_title(self, obj):
|
||||
"""Возвращает заголовок для результатов поиска."""
|
||||
return obj.title
|
||||
|
||||
def get_description(self, obj):
|
||||
"""Возвращает описание для результатов поиска."""
|
||||
# Возвращаем первые 200 символов extracted_text или description
|
||||
text = obj.extracted_text if obj.extracted_text else obj.description
|
||||
return text[:200] + '...' if len(text) > 200 else text
|
||||
|
||||
def get_url(self, obj):
|
||||
"""Возвращает URL документа."""
|
||||
return f"/documents/{obj.id}/"
|
||||
|
||||
register(
|
||||
Document,
|
||||
fields=("title", "description"),
|
||||
store=("title", "uploaded_at"),
|
||||
adapter_cls=DocumentAdapter,
|
||||
fields=("title", "description", "extracted_text"), # Добавлен extracted_text!
|
||||
store=("title", "uploaded_at", "status"),
|
||||
)
|
||||
|
||||
# 12. Уведомления (Notifications)
|
||||
|
||||
385
docs/in-progress/MAYAN_LEVEL_DMS_PLAN.md
Normal file
385
docs/in-progress/MAYAN_LEVEL_DMS_PLAN.md
Normal file
@ -0,0 +1,385 @@
|
||||
# План реализации DMS уровня Mayan EDMS
|
||||
|
||||
**Цель:** Создать корпоративную систему управления документами не ниже Mayan EDMS
|
||||
|
||||
**Дата начала:** 28 февраля 2026 г.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ключевые возможности Mayan EDMS для реализации:
|
||||
|
||||
1. ✅ **Document Storage** - django-filer + metadata
|
||||
2. ✅ **Full-text Search** - django-watson + client-side
|
||||
3. ✅ **OCR** - Tesseract.js (client-side)
|
||||
4. ✅ **Versioning** - django-reversion
|
||||
5. ✅ **Workflow** - django-fsm + визуализация
|
||||
6. ✅ **Permissions** - django-rules + filer ACL
|
||||
7. ✅ **Folders/Cabinets** - filer.Folder + Tags
|
||||
8. ✅ **PDF Operations** - pdf-lib (client-side)
|
||||
9. ✅ **Notifications** - существующая система
|
||||
10. ✅ **REST API** - DRF
|
||||
11. ✅ **Modern UI** - Next.js + React
|
||||
|
||||
---
|
||||
|
||||
## 📋 PHASE 1: Backend Foundation (2-3 дня)
|
||||
|
||||
### 1.1 Document Model Enhancement
|
||||
- [x] Добавить поле `folder` (уже сделано)
|
||||
- [ ] Добавить `extracted_text` для full-text search
|
||||
- [ ] Добавить `document_type` FK
|
||||
- [ ] Добавить `tags` M2M
|
||||
- [ ] Интегрировать с django-watson
|
||||
- [ ] Добавить django-reversion
|
||||
|
||||
### 1.2 Additional Models
|
||||
- [ ] `DocumentType` - типы документов с JSON schema метаданных
|
||||
- [ ] `DocumentMetadata` - динамические key-value метаданные
|
||||
- [ ] `DocumentTag` - теги для категоризации
|
||||
- [ ] `Cabinet` - виртуальные хранилища (MPTT)
|
||||
- [ ] `DocumentAuditLog` - полный аудит действий
|
||||
|
||||
### 1.3 Serializers & API
|
||||
- [ ] Расширить `DocumentReadSerializer` (folder, tags, metadata)
|
||||
- [ ] Создать `FolderSerializer` для filer.Folder
|
||||
- [ ] Создать `DocumentTypeSerializer`
|
||||
- [ ] Создать `TagSerializer`
|
||||
- [ ] API endpoint: `/folders/` (tree view)
|
||||
- [ ] API endpoint: `/document-types/`
|
||||
- [ ] API endpoint: `/tags/`
|
||||
- [ ] API endpoint: `/documents/{id}/versions/`
|
||||
- [ ] API endpoint: `/documents/{id}/audit-log/`
|
||||
|
||||
### 1.4 File Browser UI Integration
|
||||
- [ ] Подключить `filer.server.urls` для админки
|
||||
- [ ] Настроить FILER_* settings
|
||||
- [ ] Протестировать File Browser
|
||||
|
||||
### 1.5 Search Integration
|
||||
- [ ] Зарегистрировать Document в watson
|
||||
- [ ] API endpoint: `/search/?q=...`
|
||||
- [ ] Фильтры по типу, дате, статусу
|
||||
|
||||
**Результат Phase 1:** Полноценный backend API с папками, поиском, версионированием
|
||||
|
||||
---
|
||||
|
||||
## 📋 PHASE 2: Frontend Foundation (2-3 дня)
|
||||
|
||||
### 2.1 Install Dependencies
|
||||
```bash
|
||||
cd frontend
|
||||
npm install tesseract.js pdf-lib mammoth fuse.js reactflow recharts react-signature-canvas dnd-kit
|
||||
```
|
||||
|
||||
### 2.2 Core Components Structure
|
||||
```
|
||||
src/components/documents/
|
||||
├── browser/
|
||||
│ ├── FolderTree.tsx # Древовидная навигация
|
||||
│ ├── DocumentGrid.tsx # Grid view документов
|
||||
│ ├── DocumentList.tsx # List view документов
|
||||
│ └── Breadcrumbs.tsx # Навигация
|
||||
├── upload/
|
||||
│ ├── UploadZone.tsx # Drag & drop + processing
|
||||
│ ├── ProcessingProgress.tsx # Status bar обработки
|
||||
│ └── FilePreview.tsx # Preview перед загрузкой
|
||||
├── viewer/
|
||||
│ ├── PDFViewer.tsx # Улучшенный PDF viewer
|
||||
│ ├── ImageViewer.tsx # Lightbox для изображений
|
||||
│ └── DocumentPreview.tsx # Quick preview modal
|
||||
├── search/
|
||||
│ ├── SearchBar.tsx # Поиск с автодополнением
|
||||
│ ├── SearchFilters.tsx # Фильтры
|
||||
│ └── SearchResults.tsx # Результаты с подсветкой
|
||||
└── metadata/
|
||||
├── DocumentEditor.tsx # Форма редактирования
|
||||
├── MetadataForm.tsx # Динамические метаданные
|
||||
└── TagsInput.tsx # Теги с автодополнением
|
||||
```
|
||||
|
||||
### 2.3 API Client Extension
|
||||
- [ ] Расширить `apiClient` методами для folders
|
||||
- [ ] Методы для tags
|
||||
- [ ] Методы для document types
|
||||
- [ ] Методы для versions
|
||||
- [ ] Methods для audit log
|
||||
|
||||
### 2.4 TypeScript Types
|
||||
- [ ] `Folder` interface
|
||||
- [ ] `DocumentType` interface
|
||||
- [ ] `Tag` interface
|
||||
- [ ] `DocumentMetadata` interface
|
||||
- [ ] `AuditLogEntry` interface
|
||||
|
||||
**Результат Phase 2:** Базовый File Browser с навигацией и загрузкой
|
||||
|
||||
---
|
||||
|
||||
## 📋 PHASE 3: Client-side Processing (2-3 дня)
|
||||
|
||||
### 3.1 OCR Integration (Tesseract.js)
|
||||
```typescript
|
||||
// src/lib/ocr.ts
|
||||
import Tesseract from 'tesseract.js';
|
||||
|
||||
export async function performOCR(
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<string> {
|
||||
const { data: { text } } = await Tesseract.recognize(
|
||||
file,
|
||||
'rus+eng',
|
||||
{ logger: m => onProgress?.(m.progress * 100) }
|
||||
);
|
||||
return text;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 PDF Text Extraction
|
||||
```typescript
|
||||
// src/lib/pdf-utils.ts
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
export async function extractPDFText(file: File): Promise<string> {
|
||||
// ... implementation
|
||||
}
|
||||
|
||||
export async function generatePDFThumbnail(file: File): Promise<string> {
|
||||
// ... implementation
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 PDF Operations (pdf-lib)
|
||||
```typescript
|
||||
// src/lib/pdf-operations.ts
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export async function splitPDF(file: File, pages: number[]): Promise<Uint8Array>
|
||||
export async function mergePDFs(files: File[]): Promise<Uint8Array>
|
||||
export async function rotatePDF(file: File, rotation: number): Promise<Uint8Array>
|
||||
```
|
||||
|
||||
### 3.4 Image Processing
|
||||
```typescript
|
||||
// src/lib/image-utils.ts
|
||||
export async function compressImage(file: File, maxSizeMB: number): Promise<Blob>
|
||||
export async function cropImage(file: File, area: CropArea): Promise<Blob>
|
||||
export async function rotateImage(file: File, degrees: number): Promise<Blob>
|
||||
```
|
||||
|
||||
### 3.5 DOCX Extraction
|
||||
```typescript
|
||||
// src/lib/docx-utils.ts
|
||||
import mammoth from 'mammoth';
|
||||
|
||||
export async function extractDOCXText(file: File): Promise<string>
|
||||
```
|
||||
|
||||
### 3.6 Enhanced Upload Flow
|
||||
- [ ] Pre-processing перед загрузкой
|
||||
- [ ] Progress indicators для каждого этапа
|
||||
- [ ] Preview extracted text
|
||||
- [ ] Automatic title suggestion
|
||||
- [ ] Validation на клиенте
|
||||
|
||||
**Результат Phase 3:** Полная client-side обработка файлов
|
||||
|
||||
---
|
||||
|
||||
## 📋 PHASE 4: Advanced Features (3-4 дня)
|
||||
|
||||
### 4.1 Enhanced PDF Viewer
|
||||
- [ ] Page navigation (prev/next)
|
||||
- [ ] Zoom controls (fit-width, fit-page, custom)
|
||||
- [ ] Rotation
|
||||
- [ ] Thumbnail sidebar
|
||||
- [ ] Page search
|
||||
- [ ] Print functionality
|
||||
|
||||
### 4.2 PDF Annotations
|
||||
```typescript
|
||||
// src/components/documents/viewer/PDFAnnotations.tsx
|
||||
- [ ] Drawing tools (pen, highlighter, shapes)
|
||||
- [ ] Text annotations
|
||||
- [ ] Sticky notes
|
||||
- [ ] Save/Load annotations
|
||||
- [ ] Export annotated PDF
|
||||
```
|
||||
|
||||
### 4.3 Advanced Search
|
||||
- [ ] Fuzzy search (Fuse.js)
|
||||
- [ ] Search в extracted_text с подсветкой
|
||||
- [ ] Фильтры: дата, тип, автор, статус, теги
|
||||
- [ ] Сохраненные поиски (localStorage)
|
||||
- [ ] Recent searches
|
||||
|
||||
### 4.4 Batch Operations
|
||||
- [ ] Multiple select (checkboxes)
|
||||
- [ ] Bulk move to folder
|
||||
- [ ] Bulk add tags
|
||||
- [ ] Bulk change status
|
||||
- [ ] Bulk delete
|
||||
- [ ] Progress indicator
|
||||
|
||||
### 4.5 Dashboard & Analytics
|
||||
```typescript
|
||||
// src/app/documents/dashboard/page.tsx
|
||||
- [ ] Статистика: всего документов, по типам, по статусам
|
||||
- [ ] Recent uploads (chart)
|
||||
- [ ] Top tags cloud
|
||||
- [ ] Activity feed
|
||||
- [ ] My documents / Favorites
|
||||
```
|
||||
|
||||
**Результат Phase 4:** Rich UI с расширенными возможностями
|
||||
|
||||
---
|
||||
|
||||
## 📋 PHASE 5: Workflow & Permissions (2-3 дня)
|
||||
|
||||
### 5.1 Workflow Visualization
|
||||
```typescript
|
||||
// src/components/documents/workflow/WorkflowDiagram.tsx
|
||||
- [ ] React Flow диаграмма states/transitions
|
||||
- [ ] Highlight current state
|
||||
- [ ] Show available transitions
|
||||
- [ ] Timeline история переходов
|
||||
```
|
||||
|
||||
### 5.2 Workflow Actions UI
|
||||
- [ ] Кнопки transitions (уже есть базово)
|
||||
- [ ] Modal с комментарием при переходе
|
||||
- [ ] Assignee selection (кому назначить)
|
||||
- [ ] Due date для review
|
||||
|
||||
### 5.3 Permissions UI
|
||||
```typescript
|
||||
// src/components/documents/permissions/PermissionsEditor.tsx
|
||||
- [ ] User/Group selector
|
||||
- [ ] Permission checkboxes (view, download, edit, delete)
|
||||
- [ ] Inheritance от папки
|
||||
- [ ] ACL list display
|
||||
```
|
||||
|
||||
### 5.4 Version History
|
||||
```typescript
|
||||
// src/components/documents/versions/VersionHistory.tsx
|
||||
- [ ] Timeline версий
|
||||
- [ ] Diff viewer (сравнение метаданных)
|
||||
- [ ] Download старой версии
|
||||
- [ ] Restore к предыдущей версии
|
||||
```
|
||||
|
||||
### 5.5 Audit Log Viewer
|
||||
```typescript
|
||||
// src/components/documents/audit/AuditLog.tsx
|
||||
- [ ] Timeline всех действий
|
||||
- [ ] Filter: action type, user, date range
|
||||
- [ ] Export audit log
|
||||
```
|
||||
|
||||
**Результат Phase 5:** Полный workflow + права + аудит
|
||||
|
||||
---
|
||||
|
||||
## 📋 PHASE 6: Polish & Production (2-3 дня)
|
||||
|
||||
### 6.1 Digital Signatures
|
||||
- [ ] Backend: создание/проверка подписи
|
||||
- [ ] Frontend: signature pad component
|
||||
- [ ] Certificate upload/management
|
||||
- [ ] Visual indicator подписанных документов
|
||||
|
||||
### 6.2 Performance Optimization
|
||||
- [ ] Lazy loading компонентов
|
||||
- [ ] Virtual scrolling для больших списков (@tanstack/react-virtual)
|
||||
- [ ] Image lazy loading
|
||||
- [ ] API response caching (React Query)
|
||||
- [ ] Debouncing search
|
||||
|
||||
### 6.3 Offline Support
|
||||
- [ ] Service Worker
|
||||
- [ ] IndexedDB для offline documents
|
||||
- [ ] Sync queue для offline uploads
|
||||
|
||||
### 6.4 Mobile Responsive
|
||||
- [ ] Adaptive layout
|
||||
- [ ] Touch gestures для PDF viewer
|
||||
- [ ] Mobile upload (camera)
|
||||
|
||||
### 6.5 Testing
|
||||
- [ ] Backend: pytest для API
|
||||
- [ ] Frontend: Jest + React Testing Library
|
||||
- [ ] E2E: Playwright
|
||||
|
||||
### 6.6 Documentation
|
||||
- [ ] API documentation (Swagger/ReDoc)
|
||||
- [ ] User guide
|
||||
- [ ] Admin guide
|
||||
- [ ] Developer guide
|
||||
|
||||
**Результат Phase 6:** Production-ready система
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Итеративная разработка:
|
||||
|
||||
Каждая фаза:
|
||||
1. **Разработка** функциональности
|
||||
2. **Тестирование**
|
||||
3. **Документирование**
|
||||
4. **Коммит** в git
|
||||
5. **Демо** пользователю
|
||||
|
||||
---
|
||||
|
||||
## 📊 Метрики успеха (сравнение с Mayan EDMS):
|
||||
|
||||
| Функция | Mayan EDMS | Наша система | Статус |
|
||||
|---------|------------|--------------|--------|
|
||||
| Document Storage | ✅ | 🔄 In progress | Phase 1 |
|
||||
| Folders/Cabinets | ✅ | 🔄 In progress | Phase 1 |
|
||||
| OCR | ✅ (server) | ✅ (client) | Phase 3 |
|
||||
| Full-text Search | ✅ | 🔄 In progress | Phase 1 |
|
||||
| Workflow | ✅ | 🔄 In progress | Phase 5 |
|
||||
| Permissions | ✅ | 🔄 In progress | Phase 5 |
|
||||
| Versioning | ✅ | 🔄 In progress | Phase 1 |
|
||||
| Tags/Metadata | ✅ | 🔄 In progress | Phase 1 |
|
||||
| Digital Signatures | ✅ | ⏳ Planned | Phase 6 |
|
||||
| REST API | ✅ | 🔄 In progress | Phase 1 |
|
||||
| Modern UI | ❌ | ✅ | Phase 2-4 |
|
||||
| Client-side Processing | ❌ | ✅ | Phase 3 |
|
||||
| PDF Viewer | ✅ | ✅ (better) | Phase 4 |
|
||||
| Annotations | ✅ | ⏳ Planned | Phase 4 |
|
||||
| Dashboard | ✅ | ⏳ Planned | Phase 4 |
|
||||
|
||||
**Преимущества над Mayan:**
|
||||
- ✅ Современный React UI вместо Django templates
|
||||
- ✅ Client-side processing (быстрее, не нагружает сервер)
|
||||
- ✅ TypeScript безопасность
|
||||
- ✅ Better UX (real-time feedback, progressive enhancement)
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ Общая оценка времени:
|
||||
|
||||
- **Phase 1:** 2-3 дня (Backend Foundation)
|
||||
- **Phase 2:** 2-3 дня (Frontend Foundation)
|
||||
- **Phase 3:** 2-3 дня (Client-side Processing)
|
||||
- **Phase 4:** 3-4 дня (Advanced Features)
|
||||
- **Phase 5:** 2-3 дня (Workflow & Permissions)
|
||||
- **Phase 6:** 2-3 дня (Polish & Production)
|
||||
|
||||
**Total: 13-19 дней чистой разработки**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Начинаем с Phase 1!
|
||||
|
||||
Следующие шаги:
|
||||
1. ✅ Расширить модель Document
|
||||
2. ✅ Создать дополнительные модели
|
||||
3. ✅ Интегрировать django-watson
|
||||
4. ✅ Создать API endpoints
|
||||
5. ✅ Подключить File Browser
|
||||
Loading…
x
Reference in New Issue
Block a user