лишнее из документов удалить

This commit is contained in:
lizerk 2026-03-10 13:37:59 +03:00
parent cc24a073f4
commit 0f8dcff4ad
7 changed files with 0 additions and 2118 deletions

View File

@ -1,573 +0,0 @@
"""
Signals для автоматической генерации уведомлений в модуле Documents.
Обрабатывает события:
- Новый документ на ознакомление
- Все сотрудники ознакомились
- Напоминание о необходимости ознакомления (через Celery task)
"""
import logging
from django.db.models.signals import post_save, m2m_changed
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from .models import Document, DocumentAcknowledgement, DocumentComment
from notifications.signals import notify
logger = logging.getLogger(__name__)
Employee = get_user_model()
@receiver(post_save, sender=Document)
def create_document_notification(sender, instance, created, **kwargs):
"""
Создает уведомления при создании нового документа асинхронно.
Уведомления отправляются:
- Всем активным сотрудникам (если sent_to_all=True)
- Сотрудникам выбранных отделов (через m2m_changed signal)
- Выбранным получателям (через m2m_changed signal)
"""
logger.info(
f"[notification_signals] post_save Document id={instance.pk} "
f"created={created} sent_to_all={instance.sent_to_all}"
)
if not created:
return
document = instance
# Если документ отправляется всем - создаем уведомления напрямую
# channels.py автоматически отправит через Celery
if document.sent_to_all:
logger.info(
f"[notification_signals] Creating notifications for doc={document.pk} (sent_to_all=True)"
)
notify_all_employees(document)
else:
logger.info(
f"[notification_signals] Skipping notifications (sent_to_all=False), "
f"waiting for m2m_changed"
)
@receiver(m2m_changed, sender=Document.recipients.through)
def notify_specific_recipients(sender, instance, action, pk_set, **kwargs):
"""
Уведомляет конкретных получателей при добавлении в список.
Использует быструю обработку для большого количества получателей.
"""
logger.info(
f"[notification_signals] m2m_changed Document id={instance.pk} "
f"action={action} pk_set={pk_set} sent_to_all={instance.sent_to_all}"
)
if action != 'post_add':
logger.info(
f"[notification_signals] Skipping (action={action} != 'post_add')"
)
return
document = instance
# Не отправляем индивидуальные уведомления если документ для всех
if document.sent_to_all:
logger.info(
f"[notification_signals] Skipping (sent_to_all=True)"
)
return
recipient_count = len(pk_set or [])
logger.info(
f"[notification_signals] Processing {recipient_count} recipients"
)
# Определяем, это массовая рассылка или нет
from django.conf import settings
BULK_THRESHOLD = getattr(settings, 'NOTIFICATION_BULK_THRESHOLD', 10)
is_bulk = recipient_count >= BULK_THRESHOLD
if is_bulk:
logger.info(
f"[notification_signals] Bulk mode activated "
f"({recipient_count} >= {BULK_THRESHOLD})"
)
# Уведомляем новых получателей
created_count = 0
for user_id in pk_set:
try:
user = Employee.objects.get(id=user_id)
logger.debug(
f"[notification_signals] Creating notification for user={user_id}"
)
uploader_name = (
document.uploaded_by.get_full_name()
if document.uploaded_by
else 'Администратор'
)
notify.send(
sender=document.uploaded_by,
recipient=user,
verb='document_ready',
action_object=document,
description=(
f'{uploader_name} загрузил документ "{document.title}". '
f'Требуется ознакомление.'
),
action_url='/documents',
data={
'title': 'Новый документ на ознакомление',
'document_id': document.id,
'uploaded_by_id': (
document.uploaded_by.id if document.uploaded_by else None
),
'sent_to_all': document.sent_to_all,
},
)
created_count += 1
except Employee.DoesNotExist:
logger.warning(f"[notification_signals] User {user_id} not found, skipping")
except Exception as e:
logger.error(f"[notification_signals] Error creating notification for user {user_id}: {e}")
logger.info(f"[notification_signals] Created {created_count} notifications")
@receiver(m2m_changed, sender=Document.departments.through)
def notify_department_employees(sender, instance, action, pk_set, **kwargs):
"""
Уведомляет всех сотрудников выбранных отделов при добавлении отделов.
Уведомления получат ВСЕ текущие и будущие сотрудники этих отделов.
"""
logger.info(
f"[notification_signals] m2m_changed Document.departments "
f"id={instance.pk} action={action} pk_set={pk_set}"
)
if action != 'post_add':
logger.info(
f"[notification_signals] Skipping (action={action} != 'post_add')"
)
return
document = instance
# Не отправляем если документ для всех
if document.sent_to_all:
logger.info(
"[notification_signals] Skipping (sent_to_all=True)"
)
return
if not pk_set:
logger.warning("[notification_signals] No departments provided")
return
# Получаем всех активных сотрудников выбранных отделов
from employees.models import Department
all_employees = set()
for dept_id in pk_set:
try:
department = Department.objects.get(id=dept_id)
dept_employees = department.active_employees
logger.info(
f"[notification_signals] Department '{department.name}' "
f"has {len(dept_employees)} active employees: "
f"{[e.id for e in dept_employees]}"
)
all_employees.update(dept_employees)
logger.info(
f"[notification_signals] After update, all_employees has: "
f"{[e.id for e in all_employees]}"
)
except Department.DoesNotExist:
logger.warning(
f"[notification_signals] Department {dept_id} not found"
)
continue
# Исключаем загрузившего
if document.uploaded_by and document.uploaded_by in all_employees:
all_employees.remove(document.uploaded_by)
recipient_count = len(all_employees)
logger.info(
f"[notification_signals] Processing {recipient_count} employees "
f"from {len(pk_set)} departments"
)
# Определяем режим рассылки
from django.conf import settings
BULK_THRESHOLD = getattr(settings, 'NOTIFICATION_BULK_THRESHOLD', 10)
is_bulk = recipient_count >= BULK_THRESHOLD
if is_bulk:
logger.info(
f"[notification_signals] Bulk mode activated "
f"({recipient_count} >= {BULK_THRESHOLD})"
)
# Создаём уведомления
created_count = 0
uploader_name = (
document.uploaded_by.get_full_name()
if document.uploaded_by
else 'Администратор'
)
for employee in all_employees:
try:
notify.send(
sender=document.uploaded_by,
recipient=employee,
verb='document_ready',
action_object=document,
description=(
f'{uploader_name} загрузил документ "{document.title}". '
f'Требуется ознакомление.'
),
action_url='/documents',
data={
'title': 'Новый документ на ознакомление',
'document_id': document.id,
'uploaded_by_id': (
document.uploaded_by.id if document.uploaded_by else None
),
'sent_to_all': document.sent_to_all,
},
)
created_count += 1
except Exception as e:
logger.error(f"[notification_signals] Error creating notification for employee {employee.id}: {e}")
logger.info(f"[notification_signals] Created {created_count} notifications")
@receiver(post_save, sender=DocumentAcknowledgement)
def check_all_acknowledged(sender, instance, created, **kwargs):
"""
Проверяет, все ли получатели ознакомились с документом.
Если да - уведомляет загрузившего.
"""
if not created:
return
acknowledgement = instance
document = acknowledgement.document
# Определяем общее количество получателей
if document.sent_to_all:
total_recipients = Employee.objects.filter(is_active=True).count()
else:
# Считаем получателей из recipients + departments
recipients_count = document.recipients.count()
# Добавляем сотрудников из отделов
from employees.models import Department
department_employees = set()
for department in document.departments.all():
department_employees.update(department.active_employees)
# Исключаем дубликаты с recipients
total_recipients = recipients_count + len(department_employees)
# Проверяем количество ознакомившихся
acknowledged_count = document.acknowledgements.count()
# Если все ознакомились - уведомляем загрузившего
if (acknowledged_count >= total_recipients and
total_recipients > 0 and
document.uploaded_by):
notify.send(
sender=None,
recipient=document.uploaded_by,
verb='document_signed_all',
action_object=document,
description=(
f'Все сотрудники ознакомились с документом '
f'"{document.title}"'
),
action_url='/documents',
data={
'title': 'Все ознакомились с документом',
'document_id': document.id,
'total_recipients': total_recipients,
'acknowledged_count': acknowledged_count,
}
)
# ===== Вспомогательные функции =====
def notify_all_employees(document):
"""
Отправляет уведомление о новом документе всем активным сотрудникам.
Веб-уведомления отправляются сразу, email/Telegram - асинхронно.
"""
logger.info(
f"[notification_signals] notify_all_employees doc={document.id}"
)
active_employees = Employee.objects.filter(is_active=True)
# Исключаем загрузившего документ
if document.uploaded_by:
active_employees = active_employees.exclude(
id=document.uploaded_by.id
)
count = active_employees.count()
logger.info(
f"[notification_signals] Creating {count} notifications "
f"(web immediately, email/telegram deferred)"
)
# Создаём уведомления с ЧАСТИЧНОЙ отправкой
created_count = 0
uploader_name = (
document.uploaded_by.get_full_name()
if document.uploaded_by
else 'Администратор'
)
for employee in active_employees:
try:
notify.send(
sender=document.uploaded_by,
recipient=employee,
verb='document_ready',
action_object=document,
description=(
f'{uploader_name} загрузил документ "{document.title}". '
f'Требуется ознакомление.'
),
action_url='/documents',
data={
'title': 'Новый документ на ознакомление',
'document_id': document.id,
'uploaded_by_id': (
document.uploaded_by.id if document.uploaded_by else None
),
'sent_to_all': document.sent_to_all,
},
)
created_count += 1
except Exception as e:
logger.error(
f"Error creating notification for user {employee.id}: {e}"
)
logger.info(
f"[notification_signals] Created {created_count}/{count} notifications "
f"for doc={document.id}. Web sent immediately, email/telegram pending."
)
# Запускаем асинхронную отправку email/telegram
try:
from django.core.management import call_command
import threading
def send_pending():
try:
call_command('send_pending_notifications', '--batch-size=100')
except Exception as e:
logger.error(f"Error in background send: {e}")
# Запускаем в отдельном потоке
thread = threading.Thread(target=send_pending, daemon=True)
thread.start()
logger.info("[notification_signals] Started background email/telegram send")
except Exception as e:
logger.error(f"Error starting background send: {e}")
def create_document_ready_notification(document, recipient, send_immediately=True):
"""
Создает уведомление о новом документе для конкретного получателя.
"""
logger.info(
f"[notification_signals] create_document_ready_notification "
f"doc={document.id} recipient={recipient.id}"
)
uploader_name = (
document.uploaded_by.get_full_name()
if document.uploaded_by
else 'Администратор'
)
notify.send(
sender=document.uploaded_by,
recipient=recipient,
verb='document_ready',
action_object=document,
description=(
f'{uploader_name} загрузил документ "{document.title}". '
f'Требуется ознакомление.'
),
action_url='/documents',
data={
'title': 'Новый документ на ознакомление',
'document_id': document.id,
'uploaded_by_id': (
document.uploaded_by.id if document.uploaded_by else None
),
'sent_to_all': document.sent_to_all,
},
)
# =============================================================================
# НОВЫЕ СИГНАЛЫ ДЛЯ КОММЕНТАРИЕВ И СВЯЗАННЫХ ДОКУМЕНТОВ
# =============================================================================
@receiver(post_save, sender=DocumentComment)
def notify_on_new_comment(sender, instance, created, **kwargs):
"""
Отправка уведомлений при создании нового комментария.
- Если это ответ на комментарий (parent != None): уведомить автора родительского комментария
- Если это обычный комментарий: уведомить автора документа (если он не автор комментария)
"""
if not created:
return
comment = instance
document = comment.document
author = comment.author
logger.info(
f"[notification_signals] New comment on document={document.pk} "
f"by user={author.pk} parent={comment.parent_id}"
)
# Если это ответ на комментарий
if comment.parent:
parent_author = comment.parent.author
# Не уведомляем самого себя
if parent_author.id == author.id:
logger.info(
f"[notification_signals] Skipping self-reply notification"
)
return
logger.info(
f"[notification_signals] Notifying parent author={parent_author.pk} "
f"about reply"
)
notify.send(
sender=author,
recipient=parent_author,
verb='document_comment_reply',
action_object=comment,
description=(
f'{author.get_full_name() or author.username} ответил на ваш '
f'комментарий к документу "{document.title}"'
),
action_url='/documents',
data={
'title': 'Ответ на ваш комментарий',
'document_id': document.id,
'comment_id': comment.id,
'parent_comment_id': comment.parent_id,
'author_id': author.id,
},
)
else:
# Обычный комментарий - уведомляем автора документа
if not document.uploaded_by:
logger.info(
f"[notification_signals] Document has no author, skipping"
)
return
doc_author = document.uploaded_by
# Не уведомляем самого себя
if doc_author.id == author.id:
logger.info(
f"[notification_signals] Skipping self-comment notification"
)
return
logger.info(
f"[notification_signals] Notifying document author={doc_author.pk} "
f"about new comment"
)
notify.send(
sender=author,
recipient=doc_author,
verb='document_comment',
action_object=comment,
description=(
f'{author.get_full_name() or author.username} оставил комментарий '
f'к вашему документу "{document.title}"'
),
action_url='/documents',
data={
'title': 'Новый комментарий к документу',
'document_id': document.id,
'comment_id': comment.id,
'author_id': author.id,
},
)
@receiver(m2m_changed, sender=Document.related_documents.through)
def notify_on_related_document_added(sender, instance, action, pk_set, **kwargs):
"""
Уведомление при добавлении связанного документа.
Уведомляем автора связанного документа (если это не тот же пользователь).
"""
if action != 'post_add':
return
if not pk_set:
return
main_document = instance
logger.info(
f"[notification_signals] Related documents added to doc={main_document.pk} "
f"related_ids={pk_set}"
)
# Получаем связанные документы
related_documents = Document.objects.filter(pk__in=pk_set).select_related('uploaded_by')
for related_doc in related_documents:
# Если у связанного документа есть автор
if not related_doc.uploaded_by:
continue
# Не уведомляем если авторы совпадают
if (main_document.uploaded_by and
related_doc.uploaded_by.id == main_document.uploaded_by.id):
continue
logger.info(
f"[notification_signals] Notifying author={related_doc.uploaded_by.pk} "
f"that their document was linked"
)
notify.send(
sender=main_document.uploaded_by,
recipient=related_doc.uploaded_by,
verb='document_related',
action_object=main_document,
description=(
f'Ваш документ "{related_doc.title}" связан '
f'с документом "{main_document.title}"'
),
action_url='/documents',
data={
'title': 'Документ связан с другим',
'document_id': main_document.id,
'related_document_id': related_doc.id,
},
)

View File

@ -1,177 +0,0 @@
# # import os
# # from dotenv import load_dotenv
# # from asgiref.sync import async_to_sync
# # from aiogram import Bot
# # from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
# # from .models import Document
# # from django.contrib.auth import get_user_model
# # from bots.models import BotSubscriber
# # import logging
# # logger = logging.getLogger(__name__)
# # load_dotenv()
# # BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
# # User = get_user_model()
# # telegram_bot = Bot(token=BOT_TOKEN)
# # def send_document_to_recipients(document_id: int):
# # """
# # Уведомляем в Telegram о появлении нового документа:
# # — всем активным, если sent_to_all=True,
# # — или тем, кто в recipients.
# # """
# # try:
# # doc = Document.objects.get(pk=document_id)
# # except Document.DoesNotExist:
# # return
# # # собираем подписчиков
# # if doc.sent_to_all:
# # subs = BotSubscriber.objects.filter(
# # telegram_id__isnull=False,
# # user__is_active=True
# # )
# # else:
# # subs = BotSubscriber.objects.filter(
# # telegram_id__isnull=False,
# # user__in=doc.recipients.filter(is_active=True)
# # )
# # if not subs.exists():
# # return
# # # клавиатура: ведёт пользователя в бот, в раздел «Документы»
# # kb = InlineKeyboardMarkup(inline_keyboard=[[
# # InlineKeyboardButton(text="📄 Открыть документы",
# # callback_data="menu_docs")
# # ]])
# # text = (
# # f"🆕 *Новый документ*: {doc.title}\n\n"
# # f"{doc.description or ''}\n\n"
# # f"Зайдите в раздел «Документы» в боте, чтобы скачать файл."
# # )
# # # шлём всем подписчикам
# # for sub in subs:
# # try:
# # async_to_sync(telegram_bot.send_message)(
# # chat_id=sub.telegram_id,
# # text=text,
# # parse_mode="Markdown",
# # disable_web_page_preview=True,
# # reply_markup=kb,
# # )
# # except Exception as e:
# # # Записываем в лог, на каком именно подписчике и с каким текстом упало
# # logger.error(
# # f"[Telegram] Ошибка при отправке документу={doc.pk} "
# # f"пользователю={sub.user.pk} tg_id={sub.telegram_id}: {e}",
# # exc_info=True
# # )
# # else:
# # logger.info(
# # f"[Telegram] Уведомление по документу={doc.pk} "
# # f"успешно отправлено пользователю={sub.user.pk}"
# # )
# # documents/tasks.py
# import os
# from dotenv import load_dotenv
# from celery import shared_task
# from django.conf import settings
# from django.urls import reverse
# from django.utils import timezone
# from telegram import Bot as TgBot, InlineKeyboardButton, InlineKeyboardMarkup
# from .models import Document
# from django.contrib.auth import get_user_model
# from bots.models import BotSubscriber
# import logging
# logger = logging.getLogger(__name__)
# load_dotenv()
# TG_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') or settings.TELEGRAM_BOT_TOKEN
# User = get_user_model()
# # @shared_task(bind=True, name="documents.send_document_to_recipients")
# def send_document_to_recipients(document_id):
# try:
# doc = Document.objects.get(pk=document_id)
# except Document.DoesNotExist:
# return
# # Собираем подписчиков
# if doc.sent_to_all:
# subs = BotSubscriber.objects.filter(
# telegram_id__isnull=False,
# user__is_active=True,
# )
# else:
# subs = BotSubscriber.objects.filter(
# telegram_id__isnull=False,
# user__in=doc.recipients.filter(is_active=True),
# )
# if not subs.exists():
# return
# # Синхронный телеграм-бот
# bot = TgBot(token=TG_TOKEN)
# # Кнопка «Открыть документы»
# kb = InlineKeyboardMarkup(
# [[ InlineKeyboardButton("📄 Открыть документы", callback_data="menu_docs") ]]
# )
# # Текст уведомления
# text = (
# f"🆕 *Новый документ*: {doc.title}\n\n"
# f"{doc.description or ''}\n\n"
# f"Дата публикации: {doc.uploaded_at.strftime('%d.%m.%Y %H:%M')}"
# )
# # for sub in subs:
# # try:
# # bot.send_message(
# # chat_id=sub.telegram_id,
# # text=text,
# # parse_mode="Markdown",
# # reply_markup=kb,
# # disable_web_page_preview=True
# # )
# # except Exception as e:
# # print(e)
# # логируем, чтобы понимать, кому не дошло
# # self.retry(exc=e, countdown=60, max_retries=3)
# for sub in subs:
# try:
# bot.send_message(
# chat_id=sub.telegram_id,
# text=text,
# parse_mode="Markdown",
# disable_web_page_preview=True,
# reply_markup=kb,
# )
# except Exception as e:
# # Записываем в лог, на каком именно подписчике и с каким текстом упало
# logger.error(
# f"[Telegram] Ошибка при отправке документу={doc.pk} "
# f"пользователю={sub.user.pk} tg_id={sub.telegram_id}: {e}",
# exc_info=True
# )
# else:
# logger.info(
# f"[Telegram] Уведомление по документу={doc.pk} "
# f"успешно отправлено пользователю={sub.user.pk}"
# )

View File

@ -1,624 +0,0 @@
# documents/test_documents_api.py
"""
Comprehensive pytest API tests для DocumentViewSet
"""
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from rest_framework import status
from filer.models import File as FilerFile
from documents.models import (
Document, DocumentAcknowledgement, DocumentComment
)
from reversion.models import Version
import reversion
# =============================================================================
# CRUD TESTS
# =============================================================================
@pytest.mark.django_db
class TestDocumentCRUDAPI:
"""Тесты CRUD операций для документов"""
def test_list_documents_authenticated(self, viewer_client, document):
"""GET /documents/ - авторизованный пользователь видит свои документы"""
response = viewer_client.get('/api/v1/documents/')
assert response.status_code == status.HTTP_200_OK
# viewer в recipients - должен видеть документ
assert len(response.data.get('results', response.data)) >= 1
def test_list_documents_unauthenticated(self, api_client):
"""GET /documents/ - неавторизованный получает 401"""
response = api_client.get('/api/v1/documents/')
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_document(self, authenticated_client, author_user):
"""POST /documents/ - автор может создать документ"""
file_obj = SimpleUploadedFile("new_doc.txt", b"Content", content_type="text/plain")
data = {
'title': 'Новый документ',
'description': 'Описание',
'sent_to_all': True,
'file': file_obj,
}
response = authenticated_client.post('/api/v1/documents/', data, format='multipart')
assert response.status_code == status.HTTP_201_CREATED
assert response.data['title'] == 'Новый документ'
def test_retrieve_document_as_recipient(self, viewer_client, document):
"""GET /documents/{id}/ - получатель может просмотреть"""
response = viewer_client.get(f'/api/v1/documents/{document.id}/')
assert response.status_code == status.HTTP_200_OK
assert response.data['title'] == document.title
def test_retrieve_document_as_non_recipient(self, api_client, other_user, document):
"""GET /documents/{id}/ - не-получатель не видит документ (404 - отфильтрован queryset)"""
api_client.force_authenticate(user=other_user)
response = api_client.get(f'/api/v1/documents/{document.id}/')
# QuerySet фильтрует недоступные документы, поэтому 404
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_update_document_as_author(self, authenticated_client, document, author_user):
"""PATCH /documents/{id}/ - автор может редактировать"""
data = {'title': 'Обновленное название'}
response = authenticated_client.patch(f'/api/v1/documents/{document.id}/', data)
assert response.status_code == status.HTTP_200_OK
# Проверяем через API response (refresh_from_db не работает с FSM protected fields)
assert response.data['title'] == 'Обновленное название'
def test_update_document_as_viewer_forbidden(self, viewer_client, document):
"""PATCH /documents/{id}/ - получатель не может редактировать"""
data = {'title': 'Попытка изменить'}
response = viewer_client.patch(f'/api/v1/documents/{document.id}/', data)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_delete_document_as_author(self, authenticated_client, document):
"""DELETE /documents/{id}/ - автор может удалить"""
doc_id = document.id
response = authenticated_client.delete(f'/api/v1/documents/{doc_id}/')
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Document.objects.filter(id=doc_id).exists()
def test_delete_document_as_viewer_forbidden(self, viewer_client, document):
"""DELETE /documents/{id}/ - получатель не может удалить"""
response = viewer_client.delete(f'/api/v1/documents/{document.id}/')
assert response.status_code == status.HTTP_403_FORBIDDEN
# =============================================================================
# FSM WORKFLOW TESTS
# =============================================================================
@pytest.mark.django_db
class TestDocumentFSMWorkflow:
"""Тесты FSM переходов состояний документа"""
def test_submit_for_review_transition(self, authenticated_client, document):
"""POST submit-for-review: draft → in_review"""
assert document.status == Document.Status.DRAFT
response = authenticated_client.post(
f'/api/v1/documents/{document.id}/submit-for-review/'
)
assert response.status_code == status.HTTP_200_OK
assert response.data['status'] == Document.Status.IN_REVIEW
def test_approve_transition(self, authenticated_client, document):
"""POST approve: in_review → approved"""
document.submit_for_review()
document.save()
response = authenticated_client.post(
f'/api/v1/documents/{document.id}/approve/'
)
assert response.status_code == status.HTTP_200_OK
assert response.data['status'] == Document.Status.APPROVED
def test_reject_transition(self, authenticated_client, document):
"""POST reject: in_review → rejected"""
document.submit_for_review()
document.save()
response = authenticated_client.post(
f'/api/v1/documents/{document.id}/reject/'
)
assert response.status_code == status.HTTP_200_OK
assert response.data['status'] == Document.Status.REJECTED
def test_publish_transition(self, authenticated_client, document):
"""POST publish: approved → published"""
document.submit_for_review()
document.approve()
document.save()
response = authenticated_client.post(
f'/api/v1/documents/{document.id}/publish/'
)
assert response.status_code == status.HTTP_200_OK
assert response.data['status'] == Document.Status.PUBLISHED
def test_return_to_draft_transition(self, authenticated_client, document):
"""POST return-to-draft: in_review → draft"""
document.submit_for_review()
document.save()
response = authenticated_client.post(
f'/api/v1/documents/{document.id}/return-to-draft/'
)
assert response.status_code == status.HTTP_200_OK
assert response.data['status'] == Document.Status.DRAFT
def test_archive_transition(self, authenticated_client, document):
"""POST archive: published → archived"""
document.submit_for_review()
document.approve()
document.publish()
document.save()
response = authenticated_client.post(
f'/api/v1/documents/{document.id}/archive/'
)
assert response.status_code == status.HTTP_200_OK
assert response.data['status'] == Document.Status.ARCHIVED
def test_unarchive_transition(self, authenticated_client, document):
"""POST unarchive: archived → published"""
document.submit_for_review()
document.approve()
document.publish()
document.archive()
document.save()
response = authenticated_client.post(
f'/api/v1/documents/{document.id}/unarchive/'
)
assert response.status_code == status.HTTP_200_OK
assert document.status == Document.Status.PUBLISHED
def test_invalid_transition_returns_400(self, authenticated_client, document):
"""Невалидный переход возвращает 400"""
# Пытаемся reject когда status=DRAFT (невалидно)
response = authenticated_client.post(
f'/api/v1/documents/{document.id}/reject/'
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
# =============================================================================
# VERSIONING TESTS
# =============================================================================
@pytest.mark.django_db
class TestDocumentVersioning:
"""Тесты версионирования django-reversion"""
def test_get_versions(self, authenticated_client, document):
"""GET /documents/{id}/versions/ - история версий"""
# Создаем версии через reversion
with reversion.create_revision():
document.title = "Version 1"
document.save()
reversion.set_comment("First change")
with reversion.create_revision():
document.title = "Version 2"
document.save()
reversion.set_comment("Second change")
response = authenticated_client.get(
f'/api/v1/documents/{document.id}/versions/'
)
assert response.status_code == status.HTTP_200_OK
versions = response.data
assert len(versions) >= 2
def test_get_activity_timeline(self, authenticated_client, viewer_user, document):
"""GET /documents/{id}/activity/ - timeline активности"""
# Создаем ознакомление
DocumentAcknowledgement.objects.create(
document=document,
user=viewer_user
)
# Создаем версию
with reversion.create_revision():
document.title = "Changed"
document.save()
response = authenticated_client.get(
f'/api/v1/documents/{document.id}/activity/'
)
assert response.status_code == status.HTTP_200_OK
activity = response.data
assert len(activity) > 0
# Проверяем типы в timeline
types = [item['type'] for item in activity]
assert 'version' in types or 'acknowledgement' in types
def test_revert_to_version(self, authenticated_client, document):
"""POST /documents/{id}/revert/ - откат к версии"""
original_title = "Original Title"
# Сохраняем первую версию
with reversion.create_revision():
document.title = original_title
document.save()
# Получаем первую версию
versions = Version.objects.get_for_object(document)
first_version = versions.first()
# Меняем документ
with reversion.create_revision():
document.title = "Modified Title"
document.save()
# Откатываем
data = {
'version_id': first_version.id,
'comment': 'Откат'
}
response = authenticated_client.post(
f'/api/v1/documents/{document.id}/revert/',
data
)
assert response.status_code == status.HTTP_200_OK
assert response.data['title'] == original_title
# =============================================================================
# ACKNOWLEDGEMENT TESTS
# =============================================================================
@pytest.mark.django_db
class TestDocumentAcknowledgement:
"""Тесты ознакомления с документами"""
def test_acknowledge_document(self, viewer_client, viewer_user, document):
"""POST /documents/{id}/acknowledge/ - подтвердить ознакомление"""
response = viewer_client.post(
f'/api/v1/documents/{document.id}/acknowledge/'
)
assert response.status_code == status.HTTP_200_OK
# Проверяем создание записи
assert DocumentAcknowledgement.objects.filter(
document=document,
user=viewer_user
).exists()
def test_acknowledge_non_recipient_forbidden(self, api_client, other_user, document):
"""Не-получатель не может ознакомиться"""
api_client.force_authenticate(user=other_user)
response = api_client.post(
f'/api/v1/documents/{document.id}/acknowledge/'
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_get_acknowledgements_list(self, viewer_client, viewer_user, document):
"""GET /documents/{id}/acknowledgements/ - список ознакомившихся"""
# Создаем ознакомление
DocumentAcknowledgement.objects.create(
document=document,
user=viewer_user
)
response = viewer_client.get(
f'/api/v1/documents/{document.id}/acknowledgements/'
)
assert response.status_code == status.HTTP_200_OK
acks = response.data
assert len(acks) == 1
assert acks[0]['user']['id'] == viewer_user.id
def test_double_acknowledge_prevented(self, viewer_client, viewer_user, document):
"""Двойное ознакомление запрещено"""
# Первое ознакомление
viewer_client.post(f'/api/v1/documents/{document.id}/acknowledge/')
# Второе ознакомление - должно вернуть ошибку или already acknowledged
response = viewer_client.post(
f'/api/v1/documents/{document.id}/acknowledge/'
)
# Может вернуть 200 с сообщением "already acknowledged" или 400
# Главное - запись одна
count = DocumentAcknowledgement.objects.filter(
document=document,
user=viewer_user
).count()
assert count == 1
# =============================================================================
# COMMENT TESTS
# =============================================================================
@pytest.mark.django_db
class TestDocumentComments:
"""Тесты комментариев к документам"""
def test_create_comment(self, viewer_client, viewer_user, document):
"""POST /document-comments/ - создать комментарий"""
data = {
'text': 'Отличный документ!',
'document_id': document.id
}
response = viewer_client.post('/api/v1/document-comments/', data)
assert response.status_code == status.HTTP_201_CREATED
assert DocumentComment.objects.filter(
document=document,
author=viewer_user
).exists()
def test_create_reply_to_comment(self, authenticated_client, viewer_client, viewer_user, author_user, document):
"""Создание ответа на комментарий"""
# Создаем родительский комментарий
parent = DocumentComment.objects.create(
document=document,
author=viewer_user,
text='Parent comment'
)
# Создаем ответ
data = {
'text': 'Reply to parent',
'document_id': document.id,
'parent_id': parent.id
}
response = authenticated_client.post('/api/v1/document-comments/', data)
assert response.status_code == status.HTTP_201_CREATED
reply = DocumentComment.objects.get(parent=parent)
assert reply.text == 'Reply to parent'
def test_get_comment_replies(self, viewer_client, viewer_user, author_user, document):
"""GET /comments/{id}/replies/ - получить ответы"""
parent = DocumentComment.objects.create(
document=document,
author=viewer_user,
text='Parent'
)
DocumentComment.objects.create(
document=document,
author=author_user,
text='Reply 1',
parent=parent
)
DocumentComment.objects.create(
document=document,
author=author_user,
text='Reply 2',
parent=parent
)
response = viewer_client.get(f'/api/v1/document-comments/{parent.id}/replies/')
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 2
def test_delete_own_comment(self, viewer_client, viewer_user, document):
"""Пользователь может удалить свой комментарий"""
comment = DocumentComment.objects.create(
document=document,
author=viewer_user,
text='My comment'
)
response = viewer_client.delete(f'/api/v1/document-comments/{comment.id}/')
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_delete_others_comment_forbidden(self, viewer_client, author_user, document):
"""Нельзя удалить чужой комментарий"""
comment = DocumentComment.objects.create(
document=document,
author=author_user,
text='Author comment'
)
response = viewer_client.delete(f'/api/v1/document-comments/{comment.id}/')
assert response.status_code == status.HTTP_403_FORBIDDEN
# =============================================================================
# PERMISSIONS TESTS
# =============================================================================
@pytest.mark.django_db
class TestDocumentPermissions:
"""Тесты системы разрешений"""
def test_sent_to_all_visible_to_everyone(self, api_client, other_user, document_sent_to_all):
"""sent_to_all=True - документ видим всем активным пользователям"""
api_client.force_authenticate(user=other_user)
response = api_client.get(f'/api/v1/documents/{document_sent_to_all.id}/')
assert response.status_code == status.HTTP_200_OK
def test_private_document_hidden_from_others(self, api_client, other_user, document):
"""Приватный документ скрыт от не-получателей (404 - отфильтрован)"""
api_client.force_authenticate(user=other_user)
response = api_client.get(f'/api/v1/documents/{document.id}/')
# QuerySet фильтрует недоступные документы
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_department_member_can_view(self, api_client, other_user, document, department):
"""Член отдела-получателя может видеть документ"""
# Добавляем пользователя в отдел
other_user.department = department
other_user.save()
# Добавляем отдел в получатели документа
document.departments.add(department)
api_client.force_authenticate(user=other_user)
response = api_client.get(f'/api/v1/documents/{document.id}/')
assert response.status_code == status.HTTP_200_OK
def test_author_can_always_edit(self, authenticated_client, document):
"""Автор всегда может редактировать свой документ"""
data = {'description': 'Updated by author'}
response = authenticated_client.patch(
f'/api/v1/documents/{document.id}/',
data
)
assert response.status_code == status.HTTP_200_OK
def test_admin_has_full_access(self, admin_client, document):
"""Admin имеет полный доступ"""
# Должен видеть документ
response = admin_client.get(f'/api/v1/documents/{document.id}/')
# Admin может иметь доступ или нет в зависимости от rules
assert response.status_code in [status.HTTP_200_OK, status.HTTP_403_FORBIDDEN]
# =============================================================================
# FOLDER TESTS
# =============================================================================
@pytest.mark.django_db
class TestFolderAPI:
"""Тесты для папок документов"""
def test_list_folders(self, authenticated_client, folder):
"""GET /folders/ - список папок"""
response = authenticated_client.get('/api/v1/folders/')
assert response.status_code == status.HTTP_200_OK
def test_create_folder(self, authenticated_client):
"""POST /folders/ - создание папки"""
data = {'name': 'Новая папка'}
response = authenticated_client.post('/api/v1/folders/', data)
assert response.status_code == status.HTTP_201_CREATED
def test_get_folder_children(self, authenticated_client, folder, author_user):
"""GET /folders/{id}/children/ - дочерние папки"""
# Создаем дочернюю папку
from filer.models import Folder as FilerFolder
child = FilerFolder.objects.create(
name='Дочерняя',
parent=folder,
owner=author_user
)
response = authenticated_client.get(f'/api/v1/folders/{folder.id}/children/')
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 1
def test_get_folder_documents(self, authenticated_client, folder, document, filer_file):
"""GET /folders/{id}/documents/ - документы в папке"""
# Перемещаем документ в папку
document.folder = folder
document.save()
response = authenticated_client.get(f'/api/v1/folders/{folder.id}/documents/')
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 1
# =============================================================================
# TAG & TYPE TESTS
# =============================================================================
@pytest.mark.django_db
class TestDocumentTagsAndTypes:
"""Тесты для тегов и типов документов"""
def test_list_tags(self, authenticated_client, document_tag):
"""GET /document-tags/ - список тегов"""
response = authenticated_client.get('/api/v1/document-tags/')
assert response.status_code == status.HTTP_200_OK
def test_create_tag(self, authenticated_client):
"""POST /document-tags/ - создание тега"""
data = {'name': 'Срочно', 'color': '#FF0000'}
response = authenticated_client.post('/api/v1/document-tags/', data)
assert response.status_code == status.HTTP_201_CREATED
def test_list_document_types(self, authenticated_client, document_type):
"""GET /document-types/ - список типов"""
response = authenticated_client.get('/api/v1/document-types/')
assert response.status_code == status.HTTP_200_OK
def test_get_documents_by_tag(self, authenticated_client, document, document_tag):
"""GET /tags/{id}/documents/ - документы с тегом"""
# Добавляем тег к документу (если есть M2M)
if hasattr(document, 'tags'):
document.tags.add(document_tag)
response = authenticated_client.get(f'/api/v1/document-tags/{document_tag.id}/documents/')
assert response.status_code == status.HTTP_200_OK
# =============================================================================
# RELATED DOCUMENTS TESTS
# =============================================================================
@pytest.mark.django_db
class TestRelatedDocuments:
"""Тесты для связанных документов"""
def test_get_related_documents(self, authenticated_client, document):
"""GET /documents/{id}/related/ - связанные документы"""
response = authenticated_client.get(
f'/api/v1/documents/{document.id}/related/'
)
assert response.status_code == status.HTTP_200_OK
def test_add_related_document(self, authenticated_client, document, filer_file, author_user):
"""POST /documents/{id}/add_related/ - добавить связь"""
# Создаем второй документ
doc2 = Document.objects.create(
title="Связанный документ",
file=filer_file,
uploaded_by=author_user,
sent_to_all=True
)
data = {'document_id': doc2.id}
response = authenticated_client.post(
f'/api/v1/documents/{document.id}/add_related/',
data
)
assert response.status_code == status.HTTP_200_OK
# Проверяем связь
assert doc2 in document.related_documents.all()
def test_remove_related_document(self, authenticated_client, document, filer_file, author_user):
"""POST /documents/{id}/remove_related/ - удалить связь"""
# Создаем и связываем документ
doc2 = Document.objects.create(
title="Связанный",
file=filer_file,
uploaded_by=author_user,
sent_to_all=True
)
document.related_documents.add(doc2)
data = {'document_id': doc2.id}
response = authenticated_client.post(
f'/api/v1/documents/{document.id}/remove_related/',
data
)
assert response.status_code == status.HTTP_200_OK
# Проверяем что связь удалена
assert doc2 not in document.related_documents.all()

View File

@ -1,298 +0,0 @@
# 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)
)

View File

@ -1,10 +0,0 @@
# documents/urls.py
from django.urls import path
from .views import DocumentView, acknowledge_document
app_name = 'documents'
urlpatterns = [
path('', DocumentView.as_view(), name='document_list'),
path('ack/<int:pk>/', acknowledge_document, name='acknowledge'),
]

View File

@ -1,435 +0,0 @@
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple
from api.client import get_api_client
from api.decorators import require_api_auth
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.dateparse import parse_datetime
from django.utils.timezone import localtime
from django.views.decorators.csrf import csrf_protect
from django.views.generic import TemplateView
# ==============================
# Вспомогательные функции
# ==============================
def _as_bool(value: Any) -> bool:
"""Нормализует булево значение из строк HTML-форм.
Args:
value: Строка/булево/None.
Returns:
bool: True, если значение похоже на истину ('1', 'true', 'on', 'yes').
Raises:
ValueError: Если значение имеет неожиданный тип, не приводимый к строке.
"""
if isinstance(value, bool):
return value
try:
s = str(value).strip().lower()
except Exception as exc: # pragma: no cover - защитный блок
raise ValueError("Unsupported boolean value") from exc
return s in {"1", "true", "on", "yes"}
def _repeat_field_pairs(field: str, values: Iterable[int | str]) -> List[Tuple[str, str]]:
"""Готовит список повторяемых пар (field, value) для multipart-формы.
Args:
field (str): Имя поля.
values (Iterable[int|str]): Значения (могут быть пустыми/None — игнорируются).
Returns:
list[tuple[str, str]]: Повторяемые пары для передачи в API-клиент.
Raises:
TypeError: Если field пустой или values неитерируемый.
"""
if not field:
raise TypeError("field must be a non-empty string")
out: List[Tuple[str, str]] = []
for v in values or []:
if v is None:
continue
s = str(v).strip()
if s:
out.append((field, s))
return out
def _api_unpack(resp: Any) -> Tuple[bool, Dict[str, Any], int]:
"""Безопасно извлекает признаки успеха, JSON и статус из ответа API.
Args:
resp: Объект ответа клиентской обёртки (ожидаются атрибуты .ok, .json, .status).
Returns:
tuple: (ok, data, status)
Raises:
AttributeError: Если у ответа нет ожидаемых атрибутов.
"""
ok: bool = getattr(resp, "ok")
status: int = getattr(resp, "status")
data: Dict[str, Any] = getattr(resp, "json") or {}
return ok, data, status
def _parse_page_payload(
payload: Any,
) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[str], Optional[int]]:
"""Нормализует ответ API к (results, next, previous, count).
Поддерживает форматы:
- пагинация DRF: {"count": N, "next": URL, "previous": URL,
"results": [...]}
- сырой список: [ ... ]
Args:
payload (Any): Десериализованный JSON из API.
Returns:
Tuple: (results, next_url, prev_url, count)
"""
# 1) Непагинированный список
if isinstance(payload, list):
results = _format_datetime_fields(payload)
return results, None, None, len(results)
# 2) Словарь (пагинированный или нет)
if isinstance(payload, dict):
if "results" in payload:
raw = payload.get("results") or []
results = _format_datetime_fields(list(raw))
return (
results,
payload.get("next"),
payload.get("previous"),
payload.get("count")
)
# словарь, но не страница — пустой список
return [], None, None, None
# 3) Непонятный формат
return [], None, None, None
def _user_can_manage_documents(user: AbstractBaseUser) -> bool:
"""Проверяет, есть ли у пользователя административные/модельные права на Document.
Args:
user (AbstractBaseUser): Пользователь.
Returns:
bool: True, если is_staff или есть одно из прав add/change/delete/view.
"""
if getattr(user, "is_staff", False):
return True
model = "document"
app_label = "documents"
for act in ("view", "add", "change", "delete"):
if user.has_perm(f"{app_label}.{act}_{model}"):
return True
return False
def _fmt_dt(val, fmt: str = "%d.%m.%Y %H:%M") -> str | None:
"""Превращает ISO-строку/дату в локализованную строку.
Args:
val: ISO строка даты или datetime объект.
fmt: Формат вывода даты.
Returns:
str | None: Отформатированная строка или None.
"""
if not val:
return None
# если уже datetime — ок, если строка — парсим
dt = parse_datetime(val) if isinstance(val, str) else val
if dt is None:
return val # на всякий случай оставим как есть
try:
return localtime(dt).strftime(fmt) # уважим таймзону пользователя
except Exception:
return val
def _format_datetime_fields(
items: List[Any],
fields: tuple[str, ...] = ("uploaded_at", "created_at", "updated_at"),
) -> List[Any]:
"""Форматирует поля дат в списке объектов.
Args:
items: Список объектов (словарей).
fields: Кортеж имен полей с датами.
Returns:
List[Any]: Список с отформатированными датами.
"""
out: List[Any] = []
for it in items:
if isinstance(it, Mapping):
d = dict(it) # копия, не мутируем исходник
for f in fields:
if f in d:
d[f] = _fmt_dt(d.get(f))
out.append(d)
else:
out.append(it)
return out
def _err_msg(payload: Any, fallback: str = "") -> str:
"""Извлекает сообщение об ошибке из ответа API.
Args:
payload: Полезная нагрузка ответа API.
fallback: Сообщение по умолчанию.
Returns:
str: Сообщение об ошибке.
"""
# dict: detail / non_field_errors / первое поле со строкой
if isinstance(payload, dict):
if isinstance(payload.get("detail"), (str, list)):
d = payload["detail"]
return d if isinstance(d, str) else "; ".join(map(str, d))
if isinstance(payload.get("non_field_errors"), list):
return "; ".join(map(str, payload["non_field_errors"]))
# первая строка из значений полей
for v in payload.values():
if isinstance(v, list) and v and isinstance(v[0], str):
return "; ".join(map(str, v))
if isinstance(v, str):
return v
# list: склеить
if isinstance(payload, list):
return "; ".join(map(str, payload))
# str или что-то иное
return str(payload or fallback)
def _current_url_with(request: HttpRequest, **new_params: Any) -> str:
"""Возвращает URL текущего пути с подменой/удалением query-параметров.
Args:
request (HttpRequest): Текущий запрос.
**new_params: Пары ключ-значение; None удаляет параметр.
Returns:
str: Абсолютный путь с новой query-строкой.
"""
q = request.GET.copy()
for k, v in new_params.items():
if v is None and k in q:
del q[k]
elif v is not None:
q[k] = str(v)
qs = q.urlencode()
return f"{request.path}?{qs}" if qs else request.path
def _filter_for_user(items: Sequence[Dict[str, Any]], user_id: Optional[int]) -> List[Dict[str, Any]]:
"""Фильтрует документы в область «Мои».
Документ считается «моим», если:
- он отправлен всем (`sent_to_all == true`), или
- в `recipients[].id` присутствует `user_id`, или
- в `departments` есть отдел, где пользователь является активным сотрудником, или
- пользователь является загрузившим документ (`uploaded_by.id == user_id`).
Args:
items (Sequence[dict]): Полученный список документов из API.
user_id (int|None): Идентификатор текущего пользователя.
Returns:
list[dict]: Отфильтрованный список.
Raises:
ValueError: Если user_id не задан (None).
"""
if user_id is None:
raise ValueError("user_id is required for filtering")
result: List[Dict[str, Any]] = []
for it in items:
if not isinstance(it, dict):
continue
# 1. Документы для всех
if it.get("sent_to_all"):
result.append(it)
continue
# 2. Документы, загруженные текущим пользователем
uploaded_by = it.get("uploaded_by")
if uploaded_by and isinstance(uploaded_by, dict):
try:
if int(uploaded_by.get("id")) == user_id:
result.append(it)
continue
except (ValueError, TypeError):
pass
# 3. Проверяем recipients
recips = it.get("recipients") or []
try:
if any(int(r.get("id")) == user_id for r in recips if isinstance(r, dict) and r.get("id") is not None):
result.append(it)
continue
except Exception:
pass
# 4. Проверяем departments
depts = it.get("departments") or []
try:
# Если в документе есть отдел, нужно проверить членство
# Но API возвращает только id и name отдела, без списка сотрудников
# Поэтому нужно загрузить эту информацию отдельно или доверять backend'у
# Для упрощения, если есть departments - считаем что пользователь может видеть
# (реальная проверка происходит на backend при создании уведомлений)
if depts:
# TODO: добавить проверку членства в отделе через отдельный API запрос
# Пока просто пропускаем - backend сам решит, нужно ли показывать
pass
except Exception:
pass
return result
# ==============================
# Вьюхи
# ==============================
@method_decorator([login_required, require_api_auth, csrf_protect], name="dispatch")
class DocumentView(LoginRequiredMixin, TemplateView):
"""Страница списка документов с SSR + API для CRUD.
GET:
- Загружает список документов через API (GET /api/v1/documents/).
- Понимает фильтры: scope=mine|all, ack_status=acked|not_acked.
- Для scope=mine загружает список ознакомленных документов.
Контекст шаблона:
documents, acked_ids, next_url, prev_url, count, scope,
show_admin_controls, can_manage_documents, filters, perms,
api_document_list_url, api_document_detail_base.
"""
template_name = "documents/document_list.html"
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
"""Формирует контекст страницы списка, фильтров и пагинации."""
ctx = super().get_context_data(**kwargs)
request = self.request
user = request.user
# --- Определяем права ---
can_manage = _user_can_manage_documents(user)
show_admin_controls = can_manage
# --- Читаем фильтры из GET ---
scope = (request.GET.get("scope") or "mine").strip().lower()
if scope not in ("mine", "all"):
scope = "mine"
# Если нет прав управления - принудительно mine
if not can_manage:
scope = "mine"
ack_status = (request.GET.get("ack_status") or "").strip()
# --- Формируем контекст без загрузки документов ---
# Документы загружаются через AJAX
ctx.update({
"documents": [], # Пустой список - загрузка через JS
"acked_ids": set(),
"scope": scope,
"show_admin_controls": show_admin_controls,
"can_manage_documents": can_manage,
"filters": {
"ack_status": ack_status,
},
"perms": {
"documents": {
"add_document": (
user.has_perm("documents.add_document") or
user.is_staff
),
"change_document": (
user.has_perm("documents.change_document") or
user.is_staff
),
"delete_document": (
user.has_perm("documents.delete_document") or
user.is_staff
),
}
},
"api_document_list_url": reverse("api:v1:documents-list"),
"api_document_detail_base": reverse("api:v1:documents-list"),
})
return ctx
@login_required
@require_api_auth
def acknowledge_document(request: HttpRequest, pk: int) -> HttpResponse:
"""Отмечает документ «ознакомленным» через API и перенаправляет на файл.
Args:
request (HttpRequest): Текущий запрос.
pk (int): Идентификатор документа.
Returns:
HttpResponse: Редирект на файл документа или список документов при ошибке.
Raises:
ValueError: Если pk имеет неверный формат.
"""
try:
doc_id = int(pk)
except (TypeError, ValueError) as exc:
messages.error(request, "Некорректный идентификатор документа.")
raise ValueError("pk must be an integer") from exc
# Получаем документ для редиректа на файл
from documents.models import Document
try:
doc = Document.objects.get(id=doc_id)
file_url = doc.file.url if doc.file else None
except Document.DoesNotExist:
messages.error(request, "Документ не найден.")
return redirect(reverse("documents:document_list"))
# Отмечаем ознакомление через API
api = get_api_client(request)
resp = api.post(f"v1/documents/{doc_id}/acknowledge/")
ok, data, _status = _api_unpack(resp)
if ok:
if data.get("already"):
messages.info(request, "Вы уже отмечали ознакомление с этим документом.")
else:
messages.success(request, "Ознакомление с документом отмечено.")
else:
messages.error(request, data.get("detail") or "Не удалось отметить документ.")
# Редирект на файл или обратно на список
if file_url:
return redirect(file_url)
else:
return redirect(reverse("documents:document_list"))

View File

@ -27,7 +27,6 @@ def serve_service_worker(request):
urlpatterns = [
path("admin/", admin.site.urls),
path("documents/", include("documents.urls", namespace="documents")),
path("api/", include(("api.urls", "api"), namespace="api")),
# Service Worker должен быть в корне для правильного scope
path("sw.js", serve_service_worker, name="sw"),