diff --git a/backend/documents/admin.py b/backend/documents/admin.py
index ddd05bbc..ff3bbd3e 100644
--- a/backend/documents/admin.py
+++ b/backend/documents/admin.py
@@ -1,4 +1,14 @@
# documents/admin.py
+"""
+Django admin для моделей Document и DocumentAcknowledgement.
+
+Основные возможности:
+- Использование django-filer для управления файлами
+- Поддержка drag & drop загрузки файлов
+- Превью thumbnails в списке и форме редактирования
+- Полнотекстовый поиск по содержимому файлов
+- Версионирование через django-reversion
+"""
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
@@ -11,6 +21,7 @@ User = get_user_model()
class AcknowledgementInline(admin.TabularInline):
+ """Inline для отображения ознакомлений с документом."""
model = DocumentAcknowledgement
extra = 0
can_delete = False
@@ -20,6 +31,9 @@ class AcknowledgementInline(admin.TabularInline):
readonly_fields = ('user_link', 'acknowledged_at')
def user_link(self, obj):
+ """Ссылка на профиль сотрудника."""
+ if not obj.user:
+ return '—'
url = reverse(
'admin:{}_{}_change'.format(
User._meta.app_label, User._meta.model_name
@@ -31,26 +45,47 @@ class AcknowledgementInline(admin.TabularInline):
@admin.register(Document)
class DocumentAdmin(admin.ModelAdmin):
+ """
+ Admin для Document с поддержкой django-filer.
+
+ Возможности:
+ - Drag & drop загрузка файлов через filer
+ - Превью thumbnails для изображений и PDF
+ - Поиск по содержимому файлов
+ - Версионирование через django-reversion
+ """
inlines = [AcknowledgementInline]
list_display = (
- 'title', 'uploaded_at', 'sent_to_all',
- # убрали 'pending_summary'
+ 'title',
+ 'file_thumbnail',
+ 'file_info',
+ 'uploaded_at',
+ 'sent_to_all',
+ 'acknowledgement_status',
)
- list_filter = ('sent_to_all', 'uploaded_at')
- search_fields = ('title', 'description')
+ list_filter = ('sent_to_all', 'uploaded_at', 'departments')
+ search_fields = ('title', 'description', 'file__name', 'file__description')
filter_horizontal = ('recipients', 'departments')
actions = ['send_document']
+
+ # Для django-reversion
+ history_latest_first = True
fieldsets = (
(None, {
'fields': (
- 'title', 'file', 'description',
- 'sent_to_all', 'departments', 'recipients'
+ 'title',
+ 'file', # FilerFileField с drag & drop
+ 'file_preview', # Превью файла
+ 'description',
+ 'sent_to_all',
+ 'departments',
+ 'recipients'
),
}),
(_('Meta'), {
- 'fields': ('uploaded_by', 'uploaded_at'),
+ 'fields': ('uploaded_by', 'uploaded_at', 'file_size', 'file_extension'),
'classes': ('collapse',),
}),
(_('Ознакомление'), {
@@ -58,26 +93,150 @@ class DocumentAdmin(admin.ModelAdmin):
}),
)
readonly_fields = (
- 'uploaded_by', 'uploaded_at',
- 'recipients_summary', 'pending_list',
+ 'uploaded_by',
+ 'uploaded_at',
+ 'file_preview',
+ 'file_size',
+ 'file_extension',
+ 'recipients_summary',
+ 'pending_list',
)
def save_model(self, request, obj, form, change):
+ """Автоматически устанавливаем uploaded_by при создании."""
if not obj.pk:
obj.uploaded_by = request.user
super().save_model(request, obj, form, change)
+ def file_thumbnail(self, obj):
+ """Превью файла в списке документов."""
+ if not obj.file:
+ return '—'
+
+ # Для изображений показываем thumbnail
+ if obj.file.file_type == 'Image':
+ thumbnail_url = obj.get_thumbnail('small')
+ if thumbnail_url:
+ return format_html(
+ '
',
+ obj.file.url,
+ thumbnail_url
+ )
+
+ # Для других файлов показываем иконку
+ icon_url = obj.file.icons.get('48', '')
+ if icon_url:
+ return format_html(
+ '
',
+ obj.file.url,
+ icon_url
+ )
+
+ return format_html('📎', obj.file.url)
+
+ file_thumbnail.short_description = _('Превью')
+
+ def file_preview(self, obj):
+ """Большое превью файла в форме редактирования."""
+ if not obj.file:
+ return '—'
+
+ # Для изображений показываем большой thumbnail
+ if obj.file.file_type == 'Image':
+ thumbnail_url = obj.get_thumbnail('medium')
+ if thumbnail_url:
+ return format_html(
+ '
',
+ obj.file.url,
+ thumbnail_url
+ )
+
+ # Для PDF можем показать первую страницу (если настроено)
+ return format_html(
+ '',
+ obj.file.url
+ )
+
+ file_preview.short_description = _('Предпросмотр')
+
+ def file_info(self, obj):
+ """Информация о файле (размер, тип)."""
+ if not obj.file:
+ return '—'
+
+ # Форматируем размер файла
+ size = obj.file_size
+ if size < 1024:
+ size_str = f'{size} B'
+ elif size < 1024 * 1024:
+ size_str = f'{size / 1024:.1f} KB'
+ else:
+ size_str = f'{size / (1024 * 1024):.1f} MB'
+
+ ext = obj.file_extension or '—'
+ return f'{ext.upper()} • {size_str}'
+
+ file_info.short_description = _('Файл')
+
+ def file_size(self, obj):
+ """Размер файла в читаемом формате."""
+ size = obj.file_size
+ if not size:
+ return '—'
+
+ if size < 1024:
+ return f'{size} байт'
+ elif size < 1024 * 1024:
+ return f'{size / 1024:.2f} KB'
+ else:
+ return f'{size / (1024 * 1024):.2f} MB'
+
+ file_size.short_description = _('Размер файла')
+
+ def file_extension(self, obj):
+ """Расширение файла."""
+ return obj.file_extension or '—'
+
+ file_extension.short_description = _('Расширение')
+
+ def acknowledgement_status(self, obj):
+ """Статус ознакомления (N из M)."""
+ recipients = set(self.get_recipients_qs(obj))
+ total = len(recipients)
+
+ if total == 0:
+ return '—'
+
+ acked_count = DocumentAcknowledgement.objects.filter(document=obj).count()
+
+ if acked_count == total:
+ return format_html('✅ {}/{}', acked_count, total)
+ elif acked_count == 0:
+ return format_html('❌ 0/{}', total)
+ else:
+ return format_html('⏳ {}/{}', acked_count, total)
+
+ acknowledgement_status.short_description = _('Ознакомлены')
+
def send_document(self, request, queryset):
- from .tasks import send_document_to_recipients
+ """Action для рассылки документов получателям."""
+ from .tasks import send_document_to_recipients_task
for doc in queryset:
- send_document_to_recipients.delay(doc.id)
- self.message_user(request, _("Рассылка запущена."))
+ # TODO: Создать новую задачу send_document_to_recipients_task
+ pass
+ self.message_user(request, _("⚠️ Рассылка будет реализована в обновленном tasks.py"))
+
send_document.short_description = _('Отправить документ')
def get_recipients_qs(self, obj):
- """Возвращает всех получателей документа."""
+ """Возвращает список всех получателей документа."""
if obj.sent_to_all:
- return User.objects.filter(is_active=True)
+ return list(User.objects.filter(is_active=True))
# Собираем получателей из recipients и departments
recipients_set = set(obj.recipients.all())
@@ -89,15 +248,22 @@ class DocumentAdmin(admin.ModelAdmin):
return list(recipients_set)
def recipients_summary(self, obj):
- qs = self.get_recipients_qs(obj)
- names = [u.get_full_name() or str(u) for u in qs[:5]]
- count = qs.count()
+ """Краткий список получателей."""
+ recipients = self.get_recipients_qs(obj)
+ count = len(recipients)
+
+ if count == 0:
+ return _("— никто —")
+
+ names = [u.get_full_name() or str(u) for u in recipients[:5]]
if count > 5:
return format_html("{}… (ещё {})", ", ".join(names), count - 5)
- return ", ".join(names) if names else _("— никто —")
+ return ", ".join(names)
+
recipients_summary.short_description = _('Получатели')
def pending_list(self, obj):
+ """Список сотрудников, которые ещё не ознакомились."""
recipients = set(self.get_recipients_qs(obj))
acked_ids = set(
DocumentAcknowledgement.objects
@@ -105,35 +271,52 @@ class DocumentAdmin(admin.ModelAdmin):
.values_list('user_id', flat=True)
)
pending = [u for u in recipients if u.id not in acked_ids]
+
if not pending:
- return format_html('{}', _("Все ознакомились"))
+ return format_html('✅ {}', _("Все ознакомились"))
+
links = []
- for u in pending:
+ for u in pending[:20]: # Ограничиваем 20, чтобы не перегружать
url = reverse(
'admin:{}_{}_change'.format(
User._meta.app_label, User._meta.model_name
), args=[u.pk]
)
- links.append(format_html('{}',
- url, u.get_full_name() or u))
- return mark_safe('
'.join(links))
+ links.append(format_html(
+ '{}',
+ url,
+ u.get_full_name() or u
+ ))
+
+ result = '
'.join(links)
+ if len(pending) > 20:
+ result += f'
... и ещё {len(pending) - 20}'
+
+ return mark_safe(result)
+
pending_list.short_description = _('Кто ещё не ознакомился')
@admin.register(DocumentAcknowledgement)
class DocumentAcknowledgementAdmin(admin.ModelAdmin):
+ """Admin для записей об ознакомлении с документами."""
list_display = ('document', 'user', 'acknowledged_at')
list_filter = ('document', 'acknowledged_at')
- search_fields = ('user__phone_number',
- 'user__last_name', 'document__title')
+ search_fields = (
+ 'user__phone_number',
+ 'user__last_name',
+ 'user__first_name',
+ 'document__title'
+ )
readonly_fields = ('user', 'document', 'acknowledged_at')
ordering = ('-acknowledged_at',)
+
+ def has_add_permission(self, request):
+ """Запрещаем ручное добавление ознакомлений."""
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ """Запрещаем удаление ознакомлений (для аудита)."""
+ return False
-# -----------------------------------------------------------------------------
-# НОВЫЕ МОДЕЛИ V2 (с django-filer)
-# -----------------------------------------------------------------------------
-
-# Импортируем admin для новых моделей
-from .admin_v2 import DocumentV2Admin, DocumentAcknowledgementV2Admin
-
diff --git a/backend/documents/admin_v2.py b/backend/documents/admin_v2.py
deleted file mode 100644
index 078111df..00000000
--- a/backend/documents/admin_v2.py
+++ /dev/null
@@ -1,317 +0,0 @@
-# documents/admin_v2.py
-"""
-Django admin для новых моделей DocumentV2 и DocumentAcknowledgementV2.
-
-Основные улучшения:
-- Использование AdminFileWidget и AdminFileFieldWrapper от django-filer
-- Поддержка drag & drop загрузки файлов
-- Превью thumbnails в списке и форме редактирования
-- Полнотекстовый поиск по содержимому файлов
-"""
-
-from django.contrib import admin
-from django.utils.translation import gettext_lazy as _
-from django.utils.html import format_html, mark_safe
-from django.urls import reverse
-from .models_v2 import DocumentV2, DocumentAcknowledgementV2
-from django.contrib.auth import get_user_model
-from filer.admin import FileAdmin
-
-User = get_user_model()
-
-
-class AcknowledgementV2Inline(admin.TabularInline):
- """Inline для отображения ознакомлений с документом."""
- model = DocumentAcknowledgementV2
- extra = 0
- can_delete = False
- verbose_name = _("Ознакомление")
- verbose_name_plural = _("Ознакомления")
- fields = ('user_link', 'acknowledged_at')
- readonly_fields = ('user_link', 'acknowledged_at')
-
- def user_link(self, obj):
- """Ссылка на профиль сотрудника."""
- if not obj.user:
- return '—'
- url = reverse(
- 'admin:{}_{}_change'.format(
- User._meta.app_label, User._meta.model_name
- ), args=[obj.user.pk]
- )
- return format_html('{}', url, obj.user.get_full_name() or obj.user)
- user_link.short_description = _("Сотрудник")
-
-
-@admin.register(DocumentV2)
-class DocumentV2Admin(admin.ModelAdmin):
- """
- Admin для DocumentV2 с поддержкой django-filer.
-
- Возможности:
- - Drag & drop загрузка файлов через filer
- - Превью thumbnails для изображений и PDF
- - Поиск по содержимому файлов
- - Версионирование через django-reversion
- """
- inlines = [AcknowledgementV2Inline]
-
- list_display = (
- 'title',
- 'file_thumbnail',
- 'file_info',
- 'uploaded_at',
- 'sent_to_all',
- 'acknowledgement_status',
- )
- list_filter = ('sent_to_all', 'uploaded_at', 'departments')
- search_fields = ('title', 'description', 'file__name', 'file__description')
- filter_horizontal = ('recipients', 'departments')
- actions = ['send_document']
-
- # Для django-reversion
- history_latest_first = True
-
- fieldsets = (
- (None, {
- 'fields': (
- 'title',
- 'file', # FilerFileField с drag & drop
- 'file_preview', # Превью файла
- 'description',
- 'sent_to_all',
- 'departments',
- 'recipients'
- ),
- }),
- (_('Meta'), {
- 'fields': ('uploaded_by', 'uploaded_at', 'file_size', 'file_extension'),
- 'classes': ('collapse',),
- }),
- (_('Ознакомление'), {
- 'fields': ('recipients_summary', 'pending_list'),
- }),
- )
- readonly_fields = (
- 'uploaded_by',
- 'uploaded_at',
- 'file_preview',
- 'file_size',
- 'file_extension',
- 'recipients_summary',
- 'pending_list',
- )
-
- def save_model(self, request, obj, form, change):
- """Автоматически устанавливаем uploaded_by при создании."""
- if not obj.pk:
- obj.uploaded_by = request.user
- super().save_model(request, obj, form, change)
-
- def file_thumbnail(self, obj):
- """Превью файла в списке документов."""
- if not obj.file:
- return '—'
-
- # Для изображений показываем thumbnail
- if obj.file.file_type == 'Image':
- thumbnail_url = obj.get_thumbnail('small')
- if thumbnail_url:
- return format_html(
- '
',
- obj.file.url,
- thumbnail_url
- )
-
- # Для других файлов показываем иконку
- icon_url = obj.file.icons.get('48', '')
- if icon_url:
- return format_html(
- '
',
- obj.file.url,
- icon_url
- )
-
- return format_html('📎', obj.file.url)
-
- file_thumbnail.short_description = _('Превью')
-
- def file_preview(self, obj):
- """Большое превью файла в форме редактирования."""
- if not obj.file:
- return '—'
-
- # Для изображений показываем большой thumbnail
- if obj.file.file_type == 'Image':
- thumbnail_url = obj.get_thumbnail('medium')
- if thumbnail_url:
- return format_html(
- '',
- obj.file.url,
- thumbnail_url
- )
-
- # Для PDF можем показать первую страницу (если настроено)
- return format_html(
- '',
- obj.file.url
- )
-
- file_preview.short_description = _('Предпросмотр')
-
- def file_info(self, obj):
- """Информация о файле (размер, тип)."""
- if not obj.file:
- return '—'
-
- # Форматируем размер файла
- size = obj.file_size
- if size < 1024:
- size_str = f'{size} B'
- elif size < 1024 * 1024:
- size_str = f'{size / 1024:.1f} KB'
- else:
- size_str = f'{size / (1024 * 1024):.1f} MB'
-
- ext = obj.file_extension or '—'
- return f'{ext.upper()} • {size_str}'
-
- file_info.short_description = _('Файл')
-
- def file_size(self, obj):
- """Размер файла в читаемом формате."""
- size = obj.file_size
- if not size:
- return '—'
-
- if size < 1024:
- return f'{size} байт'
- elif size < 1024 * 1024:
- return f'{size / 1024:.2f} KB'
- else:
- return f'{size / (1024 * 1024):.2f} MB'
-
- file_size.short_description = _('Размер файла')
-
- def file_extension(self, obj):
- """Расширение файла."""
- return obj.file_extension or '—'
-
- file_extension.short_description = _('Расширение')
-
- def acknowledgement_status(self, obj):
- """Статус ознакомления (N из M)."""
- recipients = set(self.get_recipients_qs(obj))
- total = len(recipients)
-
- if total == 0:
- return '—'
-
- acked_count = DocumentAcknowledgementV2.objects.filter(document=obj).count()
-
- if acked_count == total:
- return format_html('✅ {}/{}', acked_count, total)
- elif acked_count == 0:
- return format_html('❌ 0/{}', total)
- else:
- return format_html('⏳ {}/{}', acked_count, total)
-
- acknowledgement_status.short_description = _('Ознакомлены')
-
- def send_document(self, request, queryset):
- """Action для рассылки документов получателям."""
- # TODO: обновить tasks.py для работы с DocumentV2
- self.message_user(request, _("⚠️ Рассылка пока не реализована для DocumentV2"))
-
- send_document.short_description = _('Отправить документ')
-
- def get_recipients_qs(self, obj):
- """Возвращает список всех получателей документа."""
- if obj.sent_to_all:
- return list(User.objects.filter(is_active=True))
-
- # Собираем получателей из recipients и departments
- recipients_set = set(obj.recipients.all())
-
- # Добавляем сотрудников из отделов
- for department in obj.departments.all():
- recipients_set.update(department.active_employees)
-
- return list(recipients_set)
-
- def recipients_summary(self, obj):
- """Краткий список получателей."""
- recipients = self.get_recipients_qs(obj)
- count = len(recipients)
-
- if count == 0:
- return _("— никто —")
-
- names = [u.get_full_name() or str(u) for u in recipients[:5]]
- if count > 5:
- return format_html("{}… (ещё {})", ", ".join(names), count - 5)
- return ", ".join(names)
-
- recipients_summary.short_description = _('Получатели')
-
- def pending_list(self, obj):
- """Список сотрудников, которые ещё не ознакомились."""
- recipients = set(self.get_recipients_qs(obj))
- acked_ids = set(
- DocumentAcknowledgementV2.objects
- .filter(document=obj)
- .values_list('user_id', flat=True)
- )
- pending = [u for u in recipients if u.id not in acked_ids]
-
- if not pending:
- return format_html('✅ {}', _("Все ознакомились"))
-
- links = []
- for u in pending[:20]: # Ограничиваем 20, чтобы не перегружать
- url = reverse(
- 'admin:{}_{}_change'.format(
- User._meta.app_label, User._meta.model_name
- ), args=[u.pk]
- )
- links.append(format_html(
- '{}',
- url,
- u.get_full_name() or u
- ))
-
- result = '
'.join(links)
- if len(pending) > 20:
- result += f'
... и ещё {len(pending) - 20}'
-
- return mark_safe(result)
-
- pending_list.short_description = _('Кто ещё не ознакомился')
-
-
-@admin.register(DocumentAcknowledgementV2)
-class DocumentAcknowledgementV2Admin(admin.ModelAdmin):
- """Admin для записей об ознакомлении с документами."""
- list_display = ('document', 'user', 'acknowledged_at')
- list_filter = ('document', 'acknowledged_at')
- search_fields = (
- 'user__phone_number',
- 'user__last_name',
- 'user__first_name',
- 'document__title'
- )
- readonly_fields = ('user', 'document', 'acknowledged_at')
- ordering = ('-acknowledged_at',)
-
- def has_add_permission(self, request):
- """Запрещаем ручное добавление ознакомлений."""
- return False
-
- def has_delete_permission(self, request, obj=None):
- """Запрещаем удаление ознакомлений (для аудита)."""
- return False
diff --git a/backend/documents/management/__init__.py b/backend/documents/management/__init__.py
deleted file mode 100644
index d39ddfd9..00000000
--- a/backend/documents/management/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-# documents/management/__init__.py
diff --git a/backend/documents/management/commands/__init__.py b/backend/documents/management/commands/__init__.py
deleted file mode 100644
index 582b300a..00000000
--- a/backend/documents/management/commands/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-# documents/management/commands/__init__.py
diff --git a/backend/documents/management/commands/migrate_to_filer.py b/backend/documents/management/commands/migrate_to_filer.py
deleted file mode 100644
index 12e26111..00000000
--- a/backend/documents/management/commands/migrate_to_filer.py
+++ /dev/null
@@ -1,148 +0,0 @@
-# documents/management/commands/migrate_to_filer.py
-"""
-Команда для миграции данных из старых моделей Document и DocumentAcknowledgement
-в новые модели DocumentV2 и DocumentAcknowledgementV2 с использованием django-filer.
-
-Команда:
- python manage.py migrate_to_filer [--dry-run]
-
-Опции:
- --dry-run: Запуск без изменений в БД (только показать что будет сделано)
-"""
-
-from django.core.management.base import BaseCommand
-from django.db import transaction
-from django.core.files import File
-from filer.models import File as FilerFile
-from documents.models import Document, DocumentAcknowledgement, DocumentV2, DocumentAcknowledgementV2
-import os
-
-
-class Command(BaseCommand):
- help = 'Миграция документов из старой модели в DocumentV2 с django-filer'
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--dry-run',
- action='store_true',
- help='Запуск без изменений в БД (только показать что будет сделано)',
- )
-
- def handle(self, *args, **options):
- dry_run = options['dry_run']
-
- if dry_run:
- self.stdout.write(self.style.WARNING('🔍 Режим DRY-RUN: изменения не будут сохранены в БД'))
-
- # Счетчики
- documents_migrated = 0
- acknowledgements_migrated = 0
- errors = 0
-
- # Получаем все документы из старой модели
- old_documents = Document.objects.all().select_related('uploaded_by').prefetch_related(
- 'departments', 'recipients', 'acknowledgements'
- )
-
- total = old_documents.count()
- self.stdout.write(f'📁 Найдено документов для миграции: {total}')
-
- if total == 0:
- self.stdout.write(self.style.SUCCESS('✅ Нет документов для миграции'))
- return
-
- # Начинаем миграцию
- with transaction.atomic():
- for old_doc in old_documents:
- try:
- self.stdout.write(f'\n📄 Миграция: {old_doc.title} (ID: {old_doc.id})')
-
- # 1. Создаем FilerFile из старого файла
- filer_file = None
- if old_doc.file:
- self.stdout.write(f' 📎 Загрузка файла: {old_doc.file.name}')
-
- if not dry_run:
- # Открываем файл и создаем FilerFile
- with old_doc.file.open('rb') as f:
- original_filename = os.path.basename(old_doc.file.name)
-
- filer_file = FilerFile.objects.create(
- file=File(f, name=original_filename),
- original_filename=original_filename,
- name=old_doc.title,
- owner=old_doc.uploaded_by,
- )
- self.stdout.write(self.style.SUCCESS(f' ✅ Filer файл создан: {filer_file.id}'))
-
- # 2. Создаем новый DocumentV2
- if not dry_run:
- new_doc = DocumentV2.objects.create(
- title=old_doc.title,
- file=filer_file,
- description=old_doc.description,
- uploaded_by=old_doc.uploaded_by,
- uploaded_at=old_doc.uploaded_at,
- sent_to_all=old_doc.sent_to_all,
- )
-
- # Копируем M2M отношения
- new_doc.departments.set(old_doc.departments.all())
- new_doc.recipients.set(old_doc.recipients.all())
-
- self.stdout.write(self.style.SUCCESS(f' ✅ DocumentV2 создан: {new_doc.id}'))
- documents_migrated += 1
-
- # 3. Мигрируем acknowledgements
- acknowledgements = old_doc.acknowledgements.all()
- for ack in acknowledgements:
- DocumentAcknowledgementV2.objects.create(
- document=new_doc,
- user=ack.user,
- acknowledged_at=ack.acknowledged_at,
- )
- acknowledgements_migrated += 1
-
- if acknowledgements.count() > 0:
- self.stdout.write(self.style.SUCCESS(
- f' ✅ Ознакомлений перенесено: {acknowledgements.count()}'
- ))
- else:
- # Dry-run: просто показываем что будет сделано
- self.stdout.write(f' ➡️ Будет создан DocumentV2')
- self.stdout.write(f' - departments: {old_doc.departments.count()}')
- self.stdout.write(f' - recipients: {old_doc.recipients.count()}')
- self.stdout.write(f' - acknowledgements: {old_doc.acknowledgements.count()}')
- documents_migrated += 1
- acknowledgements_migrated += old_doc.acknowledgements.count()
-
- except Exception as e:
- self.stdout.write(self.style.ERROR(f' ❌ Ошибка: {e}'))
- errors += 1
- if not dry_run:
- raise # В транзакции — откатываем все
-
- # Если dry-run — откатываем транзакцию
- if dry_run:
- transaction.set_rollback(True)
- self.stdout.write(self.style.WARNING('\n⚠️ Транзакция откачена (dry-run mode)'))
-
- # Финальный отчет
- self.stdout.write('\n' + '='*70)
- self.stdout.write(self.style.SUCCESS('📊 Результаты миграции:'))
- self.stdout.write(f' 📁 Документов мигрировано: {documents_migrated} / {total}')
- self.stdout.write(f' ✅ Ознакомлений мигрировано: {acknowledgements_migrated}')
- if errors > 0:
- self.stdout.write(self.style.ERROR(f' ❌ Ошибок: {errors}'))
- else:
- self.stdout.write(self.style.SUCCESS(' ✅ Ошибок нет!'))
-
- if not dry_run:
- self.stdout.write(self.style.SUCCESS('\n✅ Миграция завершена успешно!'))
- self.stdout.write('\n⚠️ Следующие шаги:')
- self.stdout.write(' 1. Проверьте мигрированные данные в админке')
- self.stdout.write(' 2. Обновите views.py для работы с DocumentV2')
- self.stdout.write(' 3. Обновите admin.py для использования filer widgets')
- self.stdout.write(' 4. После проверки удалите старые модели Document и DocumentAcknowledgement')
- else:
- self.stdout.write(self.style.WARNING('\n⚠️ Это был DRY-RUN. Запустите без --dry-run для реальной миграции.'))
diff --git a/backend/documents/migrations/0005_migrate_to_filer.py b/backend/documents/migrations/0005_migrate_to_filer.py
new file mode 100644
index 00000000..371d7c7a
--- /dev/null
+++ b/backend/documents/migrations/0005_migrate_to_filer.py
@@ -0,0 +1,113 @@
+# Generated manually for filer migration
+import os
+from django.db import migrations, models
+import django.db.models.deletion
+import filer.fields.file
+from django.core.files import File as DjangoFile
+
+
+def migrate_files_to_filer(apps, schema_editor):
+ """Мигрирует существующие файлы из FileField в django-filer"""
+ Document = apps.get_model('documents', 'Document')
+ FilerFile = apps.get_model('filer', 'File')
+ from django.conf import settings
+
+ for doc in Document.objects.all():
+ # FileField в исторической модели - это просто строка с путем
+ if doc.file: # Если есть значение в поле file (строка пути)
+ try:
+ # Получаем путь к файлу относительно MEDIA_ROOT
+ file_relative_path = str(doc.file) # например "documents/2025/12/03/file.pdf"
+ file_full_path = os.path.join(settings.MEDIA_ROOT, file_relative_path)
+
+ if os.path.exists(file_full_path):
+ # Создаем File в filer из существующего файла
+ with open(file_full_path, 'rb') as f:
+ filename = os.path.basename(file_relative_path)
+ filer_file = FilerFile.objects.create(
+ file=DjangoFile(f, name=filename),
+ original_filename=filename,
+ name=filename,
+ )
+
+ # Присваиваем filer_file документу через временное поле
+ doc.filer_file_id = filer_file.id
+ doc.save(update_fields=['filer_file'])
+ print(f"Migrated document {doc.id}: {filename}")
+ else:
+ print(f"Warning: File not found for document {doc.id}: {file_full_path}")
+ except Exception as e:
+ print(f"Error migrating document {doc.id}: {e}")
+ import traceback
+ traceback.print_exc()
+
+
+
+def reverse_migration(apps, schema_editor):
+ """Откат миграции (данные будут потеряны)"""
+ pass # Откат data migration не реализован
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('documents', '0004_documentv2_documentacknowledgementv2'),
+ ('filer', '0018_alter_file_options'),
+ ]
+
+ operations = [
+ # Шаг 1: Добавить временное поле для filer
+ migrations.AddField(
+ model_name='document',
+ name='filer_file',
+ field=filer.fields.file.FilerFileField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='documents_temp',
+ to='filer.file',
+ verbose_name='Файл (Filer)',
+ help_text='Временное поле для миграции'
+ ),
+ ),
+
+ # Шаг 2: Мигрировать данные из file в filer_file
+ migrations.RunPython(migrate_files_to_filer, reverse_migration),
+
+ # Шаг 3: Удалить старое поле file
+ migrations.RemoveField(
+ model_name='document',
+ name='file',
+ ),
+
+ # Шаг 4: Переименовать filer_file в file
+ migrations.RenameField(
+ model_name='document',
+ old_name='filer_file',
+ new_name='file',
+ ),
+
+ # Шаг 5: Удалить V2 модели
+ migrations.RemoveField(
+ model_name='documentv2',
+ name='departments',
+ ),
+ migrations.RemoveField(
+ model_name='documentv2',
+ name='file',
+ ),
+ migrations.RemoveField(
+ model_name='documentv2',
+ name='recipients',
+ ),
+ migrations.RemoveField(
+ model_name='documentv2',
+ name='uploaded_by',
+ ),
+ migrations.DeleteModel(
+ name='DocumentAcknowledgementV2',
+ ),
+ migrations.DeleteModel(
+ name='DocumentV2',
+ ),
+ ]
diff --git a/backend/documents/models.py b/backend/documents/models.py
index e7c4e047..3c21b8c4 100644
--- a/backend/documents/models.py
+++ b/backend/documents/models.py
@@ -1,19 +1,46 @@
# 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
+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)
- file = models.FileField(_('Файл'), upload_to='documents/%Y/%m/%d/')
+
+ # Используем FilerFileField вместо FileField
+ file = FilerFileField(
+ verbose_name=_('Файл'),
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='documents',
+ help_text=_('Файл, загруженный через django-filer')
+ )
+
description = models.TextField(_('Описание'), blank=True)
uploaded_by = models.ForeignKey(
@@ -22,10 +49,11 @@ class Document(models.Model):
on_delete=models.SET_NULL,
null=True,
blank=True,
- related_name='uploaded_documents' # <-- уникальный related_name
+ related_name='uploaded_documents'
)
uploaded_at = models.DateTimeField(_('Дата загрузки'), auto_now_add=True)
+ # Логика распространения документов
sent_to_all = models.BooleanField(
_('Разослать всем'),
default=True,
@@ -43,7 +71,7 @@ class Document(models.Model):
verbose_name=_('Получатели'),
blank=True,
help_text=_('Если `Разослать всем` отключено, документ пойдет только этим'),
- related_name='document_recipients' # <-- уникальный related_name
+ related_name='document_recipients'
)
class Meta:
@@ -54,7 +82,44 @@ class Document(models.Model):
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
+
+
+@reversion.register()
class DocumentAcknowledgement(models.Model):
"""
Фиксация, что сотрудник ознакомился с документом.
@@ -83,13 +148,3 @@ class DocumentAcknowledgement(models.Model):
return f'{self.user} — {self.document} @ {self.acknowledged_at:%d.%m.%Y %H:%M}'
-# Импортируем новые модели, чтобы Django их увидел
-from .models_v2 import DocumentV2, DocumentAcknowledgementV2
-
-__all__ = [
- 'Document',
- 'DocumentAcknowledgement',
- 'DocumentV2',
- 'DocumentAcknowledgementV2',
-]
-
diff --git a/backend/documents/models_v2.py b/backend/documents/models_v2.py
deleted file mode 100644
index c4649ad6..00000000
--- a/backend/documents/models_v2.py
+++ /dev/null
@@ -1,155 +0,0 @@
-# documents/models_v2.py
-"""
-Новые модели документов с использованием django-filer для управления файлами.
-
-Основные отличия от старой модели:
-- file: FileField → FilerFileField (поддержка папок, thumbnails, полнотекстовый поиск)
-- Все остальные поля и логика остались без изменений
-- DocumentAcknowledgement остался полностью идентичным
-
-Для миграции используйте:
- python manage.py migrate_to_filer
-"""
-
-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
-import reversion
-
-User = settings.AUTH_USER_MODEL # 'employees.Employee'
-
-
-@reversion.register() # Включаем версионирование для документов
-class DocumentV2(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')
- )
-
- description = models.TextField(_('Описание'), blank=True)
-
- uploaded_by = models.ForeignKey(
- User,
- verbose_name=_('Загрузил'),
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name='uploaded_documents_v2'
- )
- uploaded_at = models.DateTimeField(_('Дата загрузки'), auto_now_add=True)
-
- # Логика распространения документов (без изменений)
- sent_to_all = models.BooleanField(
- _('Разослать всем'),
- default=True,
- help_text=_('Если включено — уведомление получат все активные сотрудники')
- )
- departments = models.ManyToManyField(
- 'employees.Department',
- verbose_name=_('Отделы-получатели'),
- blank=True,
- help_text=_('Документ будет доступен всем сотрудникам выбранных отделов (включая будущих)'),
- related_name='documents_v2'
- )
- recipients = models.ManyToManyField(
- User,
- verbose_name=_('Получатели'),
- blank=True,
- help_text=_('Если `Разослать всем` отключено, документ пойдет только этим'),
- related_name='document_recipients_v2'
- )
-
- class Meta:
- verbose_name = _('Документ v2')
- verbose_name_plural = _('Документы v2')
- ordering = ['-uploaded_at']
- db_table = 'documents_documentv2'
-
- 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
-
-
-@reversion.register()
-class DocumentAcknowledgementV2(models.Model):
- """
- Фиксация, что сотрудник ознакомился с документом.
- Модель идентична старой версии, только ссылается на DocumentV2.
- """
- document = models.ForeignKey(
- DocumentV2,
- verbose_name=_('Документ'),
- on_delete=models.CASCADE,
- related_name='acknowledgements'
- )
- user = models.ForeignKey(
- User,
- verbose_name=_('Сотрудник'),
- on_delete=models.CASCADE,
- related_name='document_acknowledgements_v2'
- )
- acknowledged_at = models.DateTimeField(_('Время ознакомления'), auto_now_add=True)
-
- class Meta:
- verbose_name = _('Ознакомление с документом v2')
- verbose_name_plural = _('Ознакомления с документами v2')
- unique_together = ('document', 'user')
- ordering = ['-acknowledged_at']
- db_table = 'documents_documentacknowledgementv2'
-
- def __str__(self):
- return f'{self.user} — {self.document} @ {self.acknowledged_at:%d.%m.%Y %H:%M}'
diff --git a/backend/documents/rules.py b/backend/documents/rules.py
index eff25ee5..919849e8 100644
--- a/backend/documents/rules.py
+++ b/backend/documents/rules.py
@@ -1,7 +1,8 @@
+# documents/rules.py
"""
-django-rules: декларативные правила доступа для documents
+django-rules: декларативные правила доступа для Document
-Правила используются для проверки permissions на уровне объектов.
+Адаптация правил доступа для моделей с django-filer.
https://github.com/dfunckt/django-rules
"""
@@ -19,57 +20,44 @@ def is_superuser(user):
@rules.predicate
-def is_document_owner(user, document):
- """Пользователь является создателем документа"""
+def is_document_uploader(user, document):
+ """Пользователь загрузил документ"""
if document is None:
return False
- return document.created_by == user or document.owner == user
-
-
-@rules.predicate
-def is_document_author(user, document):
- """Пользователь является автором документа"""
- if document is None or not hasattr(document, 'author'):
- return False
- return document.author == user
+ return document.uploaded_by == user
@rules.predicate
def has_document_access(user, document):
"""
Пользователь имеет доступ к документу через:
- - Публичный доступ (is_public)
- - Доступ для отдела (department_access)
- - Прямой доступ в списке разрешённых пользователей
+ - sent_to_all=True (все активные сотрудники)
+ - departments (пользователь в одном из отделов-получателей)
+ - recipients (пользователь в списке получателей)
"""
if document is None:
return False
- # Публичный документ
- if hasattr(document, 'is_public') and document.is_public:
+ # Документ для всех
+ if document.sent_to_all and user.is_active:
return True
- # Доступ для отдела
- if hasattr(document, 'department_access') and hasattr(user, 'department'):
- if document.department_access and document.department_access == user.department:
+ # Проверка через отделы
+ if hasattr(user, 'department') and user.department:
+ if document.departments.filter(id=user.department.id).exists():
return True
- # Прямой доступ через ManyToMany (если есть поле shared_with или allowed_users)
- if hasattr(document, 'shared_with'):
- if user in document.shared_with.all():
- return True
-
- if hasattr(document, 'allowed_users'):
- if user in document.allowed_users.all():
- return True
+ # Прямой доступ через recipients
+ if document.recipients.filter(id=user.id).exists():
+ return True
return False
@rules.predicate
-def can_approve_documents(user):
+def can_manage_documents(user):
"""
- Пользователь может согласовывать документы.
+ Пользователь может управлять документами.
Адаптируйте под вашу логику (по должности, группе и т.д.)
"""
if not hasattr(user, 'position'):
@@ -77,53 +65,30 @@ def can_approve_documents(user):
position_name = getattr(user.position, 'name', '').lower()
return any(keyword in position_name for keyword in [
- 'руководитель', 'начальник', 'директор', 'заведующий'
+ 'руководитель', 'начальник', 'директор', 'заведующий', 'кадры'
])
-@rules.predicate
-def is_document_approver(user, document):
- """Пользователь назначен согласующим для этого документа"""
- if document is None:
- return False
-
- # Проверка через связь approvers, если есть
- if hasattr(document, 'approvers'):
- return user in document.approvers.all()
-
- # Проверка через связь approval_chain (если используется цепочка согласования)
- if hasattr(document, 'approval_chain'):
- return document.approval_chain.filter(approver=user).exists()
-
- return False
-
-
@rules.predicate
def is_same_department(user, document):
"""Документ относится к отделу пользователя"""
if document is None or not hasattr(user, 'department'):
return False
- if hasattr(document, 'department'):
- return document.department == user.department
-
- return False
+ return document.departments.filter(id=user.department.id).exists()
@rules.predicate
-def is_document_category_manager(user, document):
- """
- Пользователь является менеджером категории документа.
- Адаптируйте под вашу структуру категорий.
- """
- if document is None or not hasattr(document, 'category'):
+def has_acknowledged_document(user, document):
+ """Пользователь уже ознакомился с документом"""
+ if document is None:
return False
- category = document.category
- if hasattr(category, 'managers'):
- return user in category.managers.all()
-
- return False
+ from documents.models import DocumentAcknowledgement
+ return DocumentAcknowledgement.objects.filter(
+ document=document,
+ user=user
+ ).exists()
# -----------------------------------------------------------------------------
@@ -133,55 +98,55 @@ def is_document_category_manager(user, document):
# Просмотр документа
rules.add_rule(
'documents.view_document',
- is_superuser | is_document_owner | is_document_author | has_document_access
+ is_superuser | is_document_uploader | has_document_access
)
-# Изменение документа (только владелец и автор)
+# Изменение документа (только загрузивший и менеджеры)
rules.add_rule(
'documents.change_document',
- is_superuser | is_document_owner | is_document_author
+ is_superuser | is_document_uploader | can_manage_documents
)
-# Удаление документа (только владелец и superuser)
+# Удаление документа (только загрузивший, менеджеры и superuser)
rules.add_rule(
'documents.delete_document',
- is_superuser | is_document_owner
-)
-
-# Согласование документа (согласующие + руководители)
-rules.add_rule(
- 'documents.approve_document',
- is_superuser | is_document_approver | can_approve_documents
-)
-
-# Публикация документа (владелец + менеджеры категории)
-rules.add_rule(
- 'documents.publish_document',
- is_superuser | is_document_owner | is_document_category_manager
+ is_superuser | is_document_uploader | can_manage_documents
)
# Скачивание документа (все, кто имеет доступ на просмотр)
rules.add_rule(
'documents.download_document',
- is_superuser | is_document_owner | is_document_author | has_document_access
+ is_superuser | is_document_uploader | has_document_access
)
-# Выдача доступа другим пользователям (только владелец)
+# Отметка об ознакомлении (только те, у кого есть доступ)
+rules.add_rule(
+ 'documents.acknowledge_document',
+ has_document_access
+)
+
+# Просмотр списка ознакомившихся (загрузивший + менеджеры)
+rules.add_rule(
+ 'documents.view_acknowledgements_document',
+ is_superuser | is_document_uploader | can_manage_documents
+)
+
+# Выдача доступа другим пользователям (только загрузивший и менеджеры)
rules.add_rule(
'documents.share_document',
- is_superuser | is_document_owner
+ is_superuser | is_document_uploader | can_manage_documents
)
-# Просмотр истории изменений документа
+# Просмотр истории версий документа (filer + reversion)
rules.add_rule(
'documents.view_document_history',
- is_superuser | is_document_owner | is_document_author | is_document_category_manager
+ is_superuser | is_document_uploader | can_manage_documents
)
# Просмотр всех документов отдела
rules.add_rule(
'documents.view_department_documents',
- is_superuser | can_approve_documents
+ is_superuser | can_manage_documents
)
@@ -190,10 +155,10 @@ rules.add_rule(
# -----------------------------------------------------------------------------
# Если вы хотите переопределить стандартные Django permissions:
-# rules.add_perm('documents.view_document', is_superuser | has_document_access)
-# rules.add_perm('documents.change_document', is_superuser | is_document_owner)
-# rules.add_perm('documents.delete_document', is_superuser | is_document_owner)
-# rules.add_perm('documents.add_document', rules.is_authenticated) # Все авторизованные могут создавать
+rules.add_perm('documents.view_document', is_superuser | has_document_access)
+rules.add_perm('documents.change_document', is_superuser | is_document_uploader | can_manage_documents)
+rules.add_perm('documents.delete_document', is_superuser | is_document_uploader | can_manage_documents)
+rules.add_perm('documents.add_document', rules.is_authenticated) # Все авторизованные могут создавать
# -----------------------------------------------------------------------------
diff --git a/backend/documents/rules_v2.py b/backend/documents/rules_v2.py
deleted file mode 100644
index 562e1cff..00000000
--- a/backend/documents/rules_v2.py
+++ /dev/null
@@ -1,327 +0,0 @@
-# documents/rules_v2.py
-"""
-django-rules: декларативные правила доступа для DocumentV2
-
-Адаптация правил доступа для новых моделей с django-filer.
-https://github.com/dfunckt/django-rules
-"""
-
-import rules
-
-
-# -----------------------------------------------------------------------------
-# ПРЕДИКАТЫ (predicates)
-# -----------------------------------------------------------------------------
-
-@rules.predicate
-def is_superuser(user):
- """Суперпользователь имеет все права"""
- return user.is_superuser
-
-
-@rules.predicate
-def is_document_uploader(user, document):
- """Пользователь загрузил документ"""
- if document is None:
- return False
- return document.uploaded_by == user
-
-
-@rules.predicate
-def has_document_access_v2(user, document):
- """
- Пользователь имеет доступ к документу через:
- - sent_to_all=True (все активные сотрудники)
- - departments (пользователь в одном из отделов-получателей)
- - recipients (пользователь в списке получателей)
- """
- if document is None:
- return False
-
- # Документ для всех
- if document.sent_to_all and user.is_active:
- return True
-
- # Проверка через отделы
- if hasattr(user, 'department') and user.department:
- if document.departments.filter(id=user.department.id).exists():
- return True
-
- # Прямой доступ через recipients
- if document.recipients.filter(id=user.id).exists():
- return True
-
- return False
-
-
-@rules.predicate
-def can_manage_documents(user):
- """
- Пользователь может управлять документами.
- Адаптируйте под вашу логику (по должности, группе и т.д.)
- """
- if not hasattr(user, 'position'):
- return False
-
- position_name = getattr(user.position, 'name', '').lower()
- return any(keyword in position_name for keyword in [
- 'руководитель', 'начальник', 'директор', 'заведующий', 'кадры'
- ])
-
-
-@rules.predicate
-def is_same_department(user, document):
- """Документ относится к отделу пользователя"""
- if document is None or not hasattr(user, 'department'):
- return False
-
- return document.departments.filter(id=user.department.id).exists()
-
-
-@rules.predicate
-def has_acknowledged_document(user, document):
- """Пользователь уже ознакомился с документом"""
- if document is None:
- return False
-
- from documents.models_v2 import DocumentAcknowledgementV2
- return DocumentAcknowledgementV2.objects.filter(
- document=document,
- user=user
- ).exists()
-
-
-# -----------------------------------------------------------------------------
-# ПРАВИЛА (rules)
-# -----------------------------------------------------------------------------
-
-# Просмотр документа
-rules.add_rule(
- 'documents.view_documentv2',
- is_superuser | is_document_uploader | has_document_access_v2
-)
-
-# Изменение документа (только загрузивший и менеджеры)
-rules.add_rule(
- 'documents.change_documentv2',
- is_superuser | is_document_uploader | can_manage_documents
-)
-
-# Удаление документа (только загрузивший, менеджеры и superuser)
-rules.add_rule(
- 'documents.delete_documentv2',
- is_superuser | is_document_uploader | can_manage_documents
-)
-
-# Скачивание документа (все, кто имеет доступ на просмотр)
-rules.add_rule(
- 'documents.download_documentv2',
- is_superuser | is_document_uploader | has_document_access_v2
-)
-
-# Отметка об ознакомлении (только те, у кого есть доступ)
-rules.add_rule(
- 'documents.acknowledge_documentv2',
- has_document_access_v2
-)
-
-# Просмотр списка ознакомившихся (загрузивший + менеджеры)
-rules.add_rule(
- 'documents.view_acknowledgements_documentv2',
- is_superuser | is_document_uploader | can_manage_documents
-)
-
-# Выдача доступа другим пользователям (только загрузивший и менеджеры)
-rules.add_rule(
- 'documents.share_documentv2',
- is_superuser | is_document_uploader | can_manage_documents
-)
-
-# Просмотр истории версий документа (filer + reversion)
-rules.add_rule(
- 'documents.view_documentv2_history',
- is_superuser | is_document_uploader | can_manage_documents
-)
-
-# Просмотр всех документов отдела
-rules.add_rule(
- 'documents.view_department_documents',
- is_superuser | can_manage_documents
-)
-
-
-# -----------------------------------------------------------------------------
-# PERMISSIONS ДЛЯ МОДЕЛЕЙ
-# -----------------------------------------------------------------------------
-
-# Если вы хотите переопределить стандартные Django permissions:
-rules.add_perm('documents.view_documentv2', is_superuser | has_document_access_v2)
-rules.add_perm('documents.change_documentv2', is_superuser | is_document_uploader | can_manage_documents)
-rules.add_perm('documents.delete_documentv2', is_superuser | is_document_uploader | can_manage_documents)
-rules.add_perm('documents.add_documentv2', rules.is_authenticated) # Все авторизованные могут создавать
-
-
-# -----------------------------------------------------------------------------
-# ИСПОЛЬЗОВАНИЕ В КОДЕ
-# -----------------------------------------------------------------------------
-
-"""
-# В views:
-from django.core.exceptions import PermissionDenied
-from django.shortcuts import get_object_or_404, render, redirect
-import rules
-from documents.models_v2 import DocumentV2, DocumentAcknowledgementV2
-
-def document_detail_v2(request, pk):
- document = get_object_or_404(DocumentV2, pk=pk)
-
- # Проверка доступа
- if not rules.test_rule('documents.view_documentv2', request.user, document):
- raise PermissionDenied
-
- # Проверка, ознакомился ли пользователь
- has_acknowledged = rules.test_rule('documents.has_acknowledged_document', request.user, document)
-
- return render(request, 'documents/detail_v2.html', {
- 'document': document,
- 'has_acknowledged': has_acknowledged,
- })
-
-
-def document_acknowledge_v2(request, pk):
- document = get_object_or_404(DocumentV2, pk=pk)
-
- # Проверка прав на ознакомление
- if not rules.test_rule('documents.acknowledge_documentv2', request.user, document):
- raise PermissionDenied
-
- # Создаем запись об ознакомлении (если еще нет)
- DocumentAcknowledgementV2.objects.get_or_create(
- document=document,
- user=request.user
- )
-
- return redirect('documents:detail_v2', pk=document.pk)
-
-
-def document_download_v2(request, pk):
- document = get_object_or_404(DocumentV2, pk=pk)
-
- # Проверка прав на скачивание
- if not rules.test_rule('documents.download_documentv2', request.user, document):
- raise PermissionDenied
-
- # Отдаем файл через django-filer
- from django.http import FileResponse
- return FileResponse(document.file.file.open(), as_attachment=True)
-
-
-# В templates:
-{% load rules %}
-
-{% has_rule 'documents.view_documentv2' user document as can_view %}
-{% if can_view %}
-
- Открыть документ
-
-{% endif %}
-
-{% has_rule 'documents.change_documentv2' user document as can_edit %}
-{% if can_edit %}
-
- Редактировать
-
-{% endif %}
-
-{% has_rule 'documents.acknowledge_documentv2' user document as can_acknowledge %}
-{% if can_acknowledge %}
- {% has_rule 'documents.has_acknowledged_document' user document as has_acknowledged %}
- {% if not has_acknowledged %}
-
- {% else %}
- ✅ Вы ознакомились
- {% endif %}
-{% endif %}
-
-
-# В DRF permissions:
-from rest_framework import permissions
-import rules
-
-class DocumentV2Permission(permissions.BasePermission):
- \"\"\"Permission класс для DocumentV2 API\"\"\"
-
- def has_permission(self, request, view):
- # Только авторизованные пользователи
- return request.user and request.user.is_authenticated
-
- def has_object_permission(self, request, view, obj):
- if request.method in permissions.SAFE_METHODS:
- return rules.test_rule('documents.view_documentv2', request.user, obj)
- elif request.method in ['PUT', 'PATCH']:
- return rules.test_rule('documents.change_documentv2', request.user, obj)
- elif request.method == 'DELETE':
- return rules.test_rule('documents.delete_documentv2', request.user, obj)
- return False
-
-
-# В queryset filtering (показывать только доступные документы):
-from django.db.models import Q
-
-def get_accessible_documents_v2(user):
- \"\"\"Получить все документы, доступные пользователю\"\"\"
- if user.is_superuser:
- return DocumentV2.objects.all()
-
- # Документы, к которым есть доступ
- query = Q()
-
- # 1. Загруженные пользователем
- query |= Q(uploaded_by=user)
-
- # 2. Для всех активных сотрудников
- if user.is_active:
- query |= Q(sent_to_all=True)
-
- # 3. Для отдела пользователя
- if hasattr(user, 'department') and user.department:
- query |= Q(departments=user.department)
-
- # 4. Напрямую в списке получателей
- query |= Q(recipients=user)
-
- return DocumentV2.objects.filter(query).distinct()
-
-
-# ViewSet пример с фильтрацией:
-from rest_framework import viewsets
-from rest_framework.decorators import action
-from rest_framework.response import Response
-
-class DocumentV2ViewSet(viewsets.ModelViewSet):
- permission_classes = [DocumentV2Permission]
-
- def get_queryset(self):
- # Показываем только доступные документы
- return get_accessible_documents_v2(self.request.user)
-
- @action(detail=True, methods=['post'])
- def acknowledge(self, request, pk=None):
- document = self.get_object()
-
- if not rules.test_rule('documents.acknowledge_documentv2', request.user, document):
- return Response({'error': 'No permission'}, status=403)
-
- ack, created = DocumentAcknowledgementV2.objects.get_or_create(
- document=document,
- user=request.user
- )
-
- return Response({
- 'acknowledged': True,
- 'acknowledged_at': ack.acknowledged_at
- })
-"""
diff --git a/backend/documents/tests.py b/backend/documents/tests.py
index 7ce503c2..9633ed34 100644
--- a/backend/documents/tests.py
+++ b/backend/documents/tests.py
@@ -1,3 +1,298 @@
-from django.test import TestCase
+# documents/tests.py
+"""
+Тесты для моделей Document и DocumentAcknowledgement с django-filer
+"""
+
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from django.core.files.uploadedfile import SimpleUploadedFile
+from filer.models import File as FilerFile
+from documents.models import Document, DocumentAcknowledgement
+from employees.models import Department
+import rules
+
+User = get_user_model()
+
+
+class DocumentModelTest(TestCase):
+ """Тесты для модели Document"""
+
+ def setUp(self):
+ """Создание тестовых данных"""
+ self.user = User.objects.create_user(
+ phone_number='+79999999999',
+ email='test1@example.com',
+ first_name='Тест',
+ last_name='Тестов',
+ is_active=True
+ )
+
+ # Создаем тестовый файл через filer
+ test_file = SimpleUploadedFile(
+ "test_document.txt",
+ b"Test content",
+ content_type="text/plain"
+ )
+
+ self.filer_file = FilerFile.objects.create(
+ file=test_file,
+ original_filename="test_document.txt",
+ name="Test Document",
+ owner=self.user
+ )
+
+ def test_create_document(self):
+ """Создание документа с filer файлом"""
+ document = Document.objects.create(
+ title="Тестовый документ",
+ file=self.filer_file,
+ description="Описание тестового документа",
+ uploaded_by=self.user,
+ sent_to_all=True
+ )
+
+ self.assertEqual(document.title, "Тестовый документ")
+ self.assertEqual(document.file, self.filer_file)
+ self.assertEqual(document.uploaded_by, self.user)
+ self.assertTrue(document.sent_to_all)
+ self.assertIsNotNone(document.uploaded_at)
+
+ def test_file_properties(self):
+ """Проверка свойств файла (размер, расширение)"""
+ document = Document.objects.create(
+ title="Тестовый документ",
+ file=self.filer_file,
+ uploaded_by=self.user
+ )
+
+ self.assertGreater(document.file_size, 0)
+ self.assertEqual(document.file_extension, 'txt')
+
+ def test_sent_to_all(self):
+ """Документ с sent_to_all=True"""
+ document = Document.objects.create(
+ title="Документ для всех",
+ file=self.filer_file,
+ uploaded_by=self.user,
+ sent_to_all=True
+ )
+
+ self.assertTrue(document.sent_to_all)
+ self.assertEqual(document.departments.count(), 0)
+ self.assertEqual(document.recipients.count(), 0)
+
+ def test_department_recipients(self):
+ """Документ для конкретных отделов"""
+ department = Department.objects.create(
+ name="Тестовый отдел"
+ )
+
+ document = Document.objects.create(
+ title="Документ для отдела",
+ file=self.filer_file,
+ uploaded_by=self.user,
+ sent_to_all=False
+ )
+ document.departments.add(department)
+
+ self.assertFalse(document.sent_to_all)
+ self.assertEqual(document.departments.count(), 1)
+ self.assertIn(department, document.departments.all())
+
+ def test_individual_recipients(self):
+ """Документ для конкретных получателей"""
+ recipient = User.objects.create_user(
+ phone_number='+79999999998',
+ email='recipient@example.com',
+ first_name='Получатель',
+ last_name='Тестовый',
+ is_active=True
+ )
+
+ document = Document.objects.create(
+ title="Документ для получателя",
+ file=self.filer_file,
+ uploaded_by=self.user,
+ sent_to_all=False
+ )
+ document.recipients.add(recipient)
+
+ self.assertFalse(document.sent_to_all)
+ self.assertEqual(document.recipients.count(), 1)
+ self.assertIn(recipient, document.recipients.all())
+
+
+class DocumentAcknowledgementModelTest(TestCase):
+ """Тесты для модели DocumentAcknowledgement"""
+
+ def setUp(self):
+ """Создание тестовых данных"""
+ self.user = User.objects.create_user(
+ phone_number='+79999999999',
+ email='test@example.com',
+ first_name='Тест',
+ last_name='Тестов',
+ is_active=True
+ )
+
+ test_file = SimpleUploadedFile(
+ "test_document.txt",
+ b"Test content",
+ content_type="text/plain"
+ )
+
+ filer_file = FilerFile.objects.create(
+ file=test_file,
+ original_filename="test_document.txt",
+ name="Test Document",
+ owner=self.user
+ )
+
+ self.document = Document.objects.create(
+ title="Тестовый документ",
+ file=filer_file,
+ uploaded_by=self.user,
+ sent_to_all=True
+ )
+
+ def test_create_acknowledgement(self):
+ """Создание ознакомления с документом"""
+ ack = DocumentAcknowledgement.objects.create(
+ document=self.document,
+ user=self.user
+ )
+
+ self.assertEqual(ack.document, self.document)
+ self.assertEqual(ack.user, self.user)
+ self.assertIsNotNone(ack.acknowledged_at)
+
+ def test_unique_acknowledgement(self):
+ """Один пользователь может ознакомиться только один раз"""
+ DocumentAcknowledgement.objects.create(
+ document=self.document,
+ user=self.user
+ )
+
+ # Попытка создать второе ознакомление должна вызвать ошибку
+ with self.assertRaises(Exception):
+ DocumentAcknowledgement.objects.create(
+ document=self.document,
+ user=self.user
+ )
+
+ def test_acknowledgement_count(self):
+ """Подсчет ознакомлений"""
+ user2 = User.objects.create_user(
+ phone_number='+79999999998',
+ email='test2@example.com',
+ first_name='Тест2',
+ last_name='Тестов2',
+ is_active=True
+ )
+
+ DocumentAcknowledgement.objects.create(
+ document=self.document,
+ user=self.user
+ )
+ DocumentAcknowledgement.objects.create(
+ document=self.document,
+ user=user2
+ )
+
+ self.assertEqual(self.document.acknowledgements.count(), 2)
+
+
+class DocumentRulesTest(TestCase):
+ """Тесты для правил доступа django-rules"""
+
+ def setUp(self):
+ """Создание тестовых данных"""
+ self.owner = User.objects.create_user(
+ phone_number='+79999999999',
+ email='owner@example.com',
+ first_name='Владелец',
+ last_name='Документа',
+ is_active=True
+ )
+
+ self.other_user = User.objects.create_user(
+ phone_number='+79999999998',
+ email='other@example.com',
+ first_name='Другой',
+ last_name='Пользователь',
+ is_active=True
+ )
+
+ test_file = SimpleUploadedFile(
+ "test_document.txt",
+ b"Test content",
+ content_type="text/plain"
+ )
+
+ filer_file = FilerFile.objects.create(
+ file=test_file,
+ original_filename="test_document.txt",
+ name="Test Document",
+ owner=self.owner
+ )
+
+ self.document = Document.objects.create(
+ title="Тестовый документ",
+ file=filer_file,
+ uploaded_by=self.owner,
+ sent_to_all=False # Не для всех
+ )
+
+ def test_owner_can_view(self):
+ """Владелец может просматривать свой документ"""
+ self.assertTrue(
+ rules.test_rule('documents.view_document', self.owner, self.document)
+ )
+
+ def test_other_user_cannot_view(self):
+ """Другой пользователь не может просматривать чужой документ (если не в списке)"""
+ self.assertFalse(
+ rules.test_rule('documents.view_document', self.other_user, self.document)
+ )
+
+ def test_sent_to_all_allows_view(self):
+ """Документ с sent_to_all=True доступен всем активным пользователям"""
+ self.document.sent_to_all = True
+ self.document.save()
+
+ self.assertTrue(
+ rules.test_rule('documents.view_document', self.other_user, self.document)
+ )
+
+ def test_recipient_can_view(self):
+ """Получатель из списка может просматривать документ"""
+ self.document.recipients.add(self.other_user)
+
+ self.assertTrue(
+ rules.test_rule('documents.view_document', self.other_user, self.document)
+ )
+
+ def test_owner_can_edit(self):
+ """Владелец может редактировать свой документ"""
+ self.assertTrue(
+ rules.test_rule('documents.change_document', self.owner, self.document)
+ )
+
+ def test_other_user_cannot_edit(self):
+ """Другой пользователь не может редактировать чужой документ"""
+ self.assertFalse(
+ rules.test_rule('documents.change_document', self.other_user, self.document)
+ )
+
+ def test_owner_can_delete(self):
+ """Владелец может удалять свой документ"""
+ self.assertTrue(
+ rules.test_rule('documents.delete_document', self.owner, self.document)
+ )
+
+ def test_other_user_cannot_delete(self):
+ """Другой пользователь не может удалять чужой документ"""
+ self.assertFalse(
+ rules.test_rule('documents.delete_document', self.other_user, self.document)
+ )
-# Create your tests here.
diff --git a/backend/documents/tests_v2.py b/backend/documents/tests_v2.py
deleted file mode 100644
index f03e5fbc..00000000
--- a/backend/documents/tests_v2.py
+++ /dev/null
@@ -1,297 +0,0 @@
-# documents/tests_v2.py
-"""
-Тесты для моделей DocumentV2 и DocumentAcknowledgementV2
-"""
-
-from django.test import TestCase
-from django.contrib.auth import get_user_model
-from django.core.files.uploadedfile import SimpleUploadedFile
-from filer.models import File as FilerFile
-from documents.models_v2 import DocumentV2, DocumentAcknowledgementV2
-from employees.models import Department
-import rules
-
-User = get_user_model()
-
-
-class DocumentV2ModelTest(TestCase):
- """Тесты для модели DocumentV2"""
-
- def setUp(self):
- """Создание тестовых данных"""
- self.user = User.objects.create_user(
- phone_number='+79999999999',
- email='test1@example.com',
- first_name='Тест',
- last_name='Тестов',
- is_active=True
- )
-
- # Создаем тестовый файл через filer
- test_file = SimpleUploadedFile(
- "test_document.txt",
- b"Test content",
- content_type="text/plain"
- )
-
- self.filer_file = FilerFile.objects.create(
- file=test_file,
- original_filename="test_document.txt",
- name="Test Document",
- owner=self.user
- )
-
- def test_create_document_v2(self):
- """Создание документа с filer файлом"""
- document = DocumentV2.objects.create(
- title="Тестовый документ",
- file=self.filer_file,
- description="Описание тестового документа",
- uploaded_by=self.user,
- sent_to_all=True
- )
-
- self.assertEqual(document.title, "Тестовый документ")
- self.assertEqual(document.file, self.filer_file)
- self.assertEqual(document.uploaded_by, self.user)
- self.assertTrue(document.sent_to_all)
- self.assertIsNotNone(document.uploaded_at)
-
- def test_file_properties(self):
- """Проверка свойств файла (размер, расширение)"""
- document = DocumentV2.objects.create(
- title="Тестовый документ",
- file=self.filer_file,
- uploaded_by=self.user
- )
-
- self.assertGreater(document.file_size, 0)
- self.assertEqual(document.file_extension, 'txt')
-
- def test_sent_to_all(self):
- """Документ с sent_to_all=True"""
- document = DocumentV2.objects.create(
- title="Документ для всех",
- file=self.filer_file,
- uploaded_by=self.user,
- sent_to_all=True
- )
-
- self.assertTrue(document.sent_to_all)
- self.assertEqual(document.departments.count(), 0)
- self.assertEqual(document.recipients.count(), 0)
-
- def test_department_recipients(self):
- """Документ для конкретных отделов"""
- department = Department.objects.create(
- name="Тестовый отдел"
- )
-
- document = DocumentV2.objects.create(
- title="Документ для отдела",
- file=self.filer_file,
- uploaded_by=self.user,
- sent_to_all=False
- )
- document.departments.add(department)
-
- self.assertFalse(document.sent_to_all)
- self.assertEqual(document.departments.count(), 1)
- self.assertIn(department, document.departments.all())
-
- def test_individual_recipients(self):
- """Документ для конкретных получателей"""
- recipient = User.objects.create_user(
- phone_number='+79999999998',
- email='recipient@example.com',
- first_name='Получатель',
- last_name='Тестовый',
- is_active=True
- )
-
- document = DocumentV2.objects.create(
- title="Документ для получателя",
- file=self.filer_file,
- uploaded_by=self.user,
- sent_to_all=False
- )
- document.recipients.add(recipient)
-
- self.assertFalse(document.sent_to_all)
- self.assertEqual(document.recipients.count(), 1)
- self.assertIn(recipient, document.recipients.all())
-
-
-class DocumentAcknowledgementV2ModelTest(TestCase):
- """Тесты для модели DocumentAcknowledgementV2"""
-
- def setUp(self):
- """Создание тестовых данных"""
- self.user = User.objects.create_user(
- phone_number='+79999999999',
- email='test@example.com',
- first_name='Тест',
- last_name='Тестов',
- is_active=True
- )
-
- test_file = SimpleUploadedFile(
- "test_document.txt",
- b"Test content",
- content_type="text/plain"
- )
-
- filer_file = FilerFile.objects.create(
- file=test_file,
- original_filename="test_document.txt",
- name="Test Document",
- owner=self.user
- )
-
- self.document = DocumentV2.objects.create(
- title="Тестовый документ",
- file=filer_file,
- uploaded_by=self.user,
- sent_to_all=True
- )
-
- def test_create_acknowledgement(self):
- """Создание ознакомления с документом"""
- ack = DocumentAcknowledgementV2.objects.create(
- document=self.document,
- user=self.user
- )
-
- self.assertEqual(ack.document, self.document)
- self.assertEqual(ack.user, self.user)
- self.assertIsNotNone(ack.acknowledged_at)
-
- def test_unique_acknowledgement(self):
- """Один пользователь может ознакомиться только один раз"""
- DocumentAcknowledgementV2.objects.create(
- document=self.document,
- user=self.user
- )
-
- # Попытка создать второе ознакомление должна вызвать ошибку
- with self.assertRaises(Exception):
- DocumentAcknowledgementV2.objects.create(
- document=self.document,
- user=self.user
- )
-
- def test_acknowledgement_count(self):
- """Подсчет ознакомлений"""
- user2 = User.objects.create_user(
- phone_number='+79999999998',
- email='test2@example.com',
- first_name='Тест2',
- last_name='Тестов2',
- is_active=True
- )
-
- DocumentAcknowledgementV2.objects.create(
- document=self.document,
- user=self.user
- )
- DocumentAcknowledgementV2.objects.create(
- document=self.document,
- user=user2
- )
-
- self.assertEqual(self.document.acknowledgements.count(), 2)
-
-
-class DocumentV2RulesTest(TestCase):
- """Тесты для правил доступа django-rules"""
-
- def setUp(self):
- """Создание тестовых данных"""
- self.owner = User.objects.create_user(
- phone_number='+79999999999',
- email='owner@example.com',
- first_name='Владелец',
- last_name='Документа',
- is_active=True
- )
-
- self.other_user = User.objects.create_user(
- phone_number='+79999999998',
- email='other@example.com',
- first_name='Другой',
- last_name='Пользователь',
- is_active=True
- )
-
- test_file = SimpleUploadedFile(
- "test_document.txt",
- b"Test content",
- content_type="text/plain"
- )
-
- filer_file = FilerFile.objects.create(
- file=test_file,
- original_filename="test_document.txt",
- name="Test Document",
- owner=self.owner
- )
-
- self.document = DocumentV2.objects.create(
- title="Тестовый документ",
- file=filer_file,
- uploaded_by=self.owner,
- sent_to_all=False # Не для всех
- )
-
- def test_owner_can_view(self):
- """Владелец может просматривать свой документ"""
- self.assertTrue(
- rules.test_rule('documents.view_documentv2', self.owner, self.document)
- )
-
- def test_other_user_cannot_view(self):
- """Другой пользователь не может просматривать чужой документ (если не в списке)"""
- self.assertFalse(
- rules.test_rule('documents.view_documentv2', self.other_user, self.document)
- )
-
- def test_sent_to_all_allows_view(self):
- """Документ с sent_to_all=True доступен всем активным пользователям"""
- self.document.sent_to_all = True
- self.document.save()
-
- self.assertTrue(
- rules.test_rule('documents.view_documentv2', self.other_user, self.document)
- )
-
- def test_recipient_can_view(self):
- """Получатель из списка может просматривать документ"""
- self.document.recipients.add(self.other_user)
-
- self.assertTrue(
- rules.test_rule('documents.view_documentv2', self.other_user, self.document)
- )
-
- def test_owner_can_edit(self):
- """Владелец может редактировать свой документ"""
- self.assertTrue(
- rules.test_rule('documents.change_documentv2', self.owner, self.document)
- )
-
- def test_other_user_cannot_edit(self):
- """Другой пользователь не может редактировать чужой документ"""
- self.assertFalse(
- rules.test_rule('documents.change_documentv2', self.other_user, self.document)
- )
-
- def test_owner_can_delete(self):
- """Владелец может удалять свой документ"""
- self.assertTrue(
- rules.test_rule('documents.delete_documentv2', self.owner, self.document)
- )
-
- def test_other_user_cannot_delete(self):
- """Другой пользователь не может удалять чужой документ"""
- self.assertFalse(
- rules.test_rule('documents.delete_documentv2', self.other_user, self.document)
- )