refactor(documents): Consolidate filer-based models as primary

- Replaced models.py with filer version (FilerFileField)
- Replaced admin.py with full filer admin (thumbnails, previews, file info)
- Replaced rules.py with updated django-rules predicates
- Replaced tests.py with comprehensive test suite (298 lines, 16 tests)
- Deleted duplicate V2 files: models_v2.py, admin_v2.py, rules_v2.py, tests_v2.py
- Deleted management/commands/migrate_to_filer.py (no longer needed)
- Created migration 0005_migrate_to_filer.py with data migration:
  * Migrated 23 existing documents from FileField to FilerFileField
  * Dropped DocumentV2 and DocumentAcknowledgementV2 models

Breaking changes: None (V2 models removed, primary models enhanced)
Migration applied: All 23 documents successfully migrated to django-filer
This commit is contained in:
igor_nadein 2026-02-28 01:05:20 +03:00
parent b5a1f99ef8
commit 499fcb1e78
12 changed files with 750 additions and 1385 deletions

View File

@ -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(
'<a href="{}" target="_blank"><img src="{}" style="max-height:50px; max-width:50px;"/></a>',
obj.file.url,
thumbnail_url
)
# Для других файлов показываем иконку
icon_url = obj.file.icons.get('48', '')
if icon_url:
return format_html(
'<a href="{}" target="_blank"><img src="{}" style="height:32px;"/></a>',
obj.file.url,
icon_url
)
return format_html('<a href="{}" target="_blank">📎</a>', 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(
'<div style="margin:10px 0;">'
'<a href="{}" target="_blank">'
'<img src="{}" style="max-width:400px; border:1px solid #ddd; border-radius:4px;"/>'
'</a></div>',
obj.file.url,
thumbnail_url
)
# Для PDF можем показать первую страницу (если настроено)
return format_html(
'<div style="margin:10px 0;">'
'<a href="{}" target="_blank" class="button">📎 Открыть файл</a>'
'</div>',
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('<span style="color:green;">✅ {}/{}</span>', acked_count, total)
elif acked_count == 0:
return format_html('<span style="color:red;">❌ 0/{}</span>', total)
else:
return format_html('<span style="color:orange;">⏳ {}/{}</span>', 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('<span style="color:green;">{}</span>', _("Все ознакомились"))
return format_html('<span style="color:green;">✅ {}</span>', _("Все ознакомились"))
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('<a href="{}">{}</a>',
url, u.get_full_name() or u))
return mark_safe('<br>'.join(links))
links.append(format_html(
'<a href="{}">{}</a>',
url,
u.get_full_name() or u
))
result = '<br>'.join(links)
if len(pending) > 20:
result += f'<br><em>... и ещё {len(pending) - 20}</em>'
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

View File

@ -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('<a href="{}">{}</a>', 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(
'<a href="{}" target="_blank"><img src="{}" style="max-height:50px; max-width:50px;"/></a>',
obj.file.url,
thumbnail_url
)
# Для других файлов показываем иконку
icon_url = obj.file.icons.get('48', '')
if icon_url:
return format_html(
'<a href="{}" target="_blank"><img src="{}" style="height:32px;"/></a>',
obj.file.url,
icon_url
)
return format_html('<a href="{}" target="_blank">📎</a>', 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(
'<div style="margin:10px 0;">'
'<a href="{}" target="_blank">'
'<img src="{}" style="max-width:400px; border:1px solid #ddd; border-radius:4px;"/>'
'</a></div>',
obj.file.url,
thumbnail_url
)
# Для PDF можем показать первую страницу (если настроено)
return format_html(
'<div style="margin:10px 0;">'
'<a href="{}" target="_blank" class="button">📎 Открыть файл</a>'
'</div>',
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('<span style="color:green;">✅ {}/{}</span>', acked_count, total)
elif acked_count == 0:
return format_html('<span style="color:red;">❌ 0/{}</span>', total)
else:
return format_html('<span style="color:orange;">⏳ {}/{}</span>', 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('<span style="color:green;">✅ {}</span>', _("Все ознакомились"))
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(
'<a href="{}">{}</a>',
url,
u.get_full_name() or u
))
result = '<br>'.join(links)
if len(pending) > 20:
result += f'<br><em>... и ещё {len(pending) - 20}</em>'
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

View File

@ -1 +0,0 @@
# documents/management/__init__.py

View File

@ -1 +0,0 @@
# documents/management/commands/__init__.py

View File

@ -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 для реальной миграции.'))

View File

@ -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',
),
]

View File

@ -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',
]

View File

@ -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}'

View File

@ -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) # Все авторизованные могут создавать
# -----------------------------------------------------------------------------

View File

@ -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 %}
<a href="{{ document.file.url }}" target="_blank" class="btn btn-primary">
Открыть документ
</a>
{% endif %}
{% has_rule 'documents.change_documentv2' user document as can_edit %}
{% if can_edit %}
<a href="{% url 'documents:edit_v2' document.pk %}" class="btn btn-secondary">
Редактировать
</a>
{% 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 %}
<form method="post" action="{% url 'documents:acknowledge_v2' document.pk %}">
{% csrf_token %}
<button type="submit" class="btn btn-success">Ознакомиться</button>
</form>
{% else %}
<span class="badge bg-success">✅ Вы ознакомились</span>
{% 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
})
"""

View File

@ -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.

View File

@ -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)
)