diff --git a/backend/api/v1/documents/permissions.py b/backend/api/v1/documents/permissions.py index 17d3326a..bd6396e0 100644 --- a/backend/api/v1/documents/permissions.py +++ b/backend/api/v1/documents/permissions.py @@ -64,6 +64,7 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): "archive", "comments", "document_comment", + "set_maximally_hidden", ): return bool(request.user and request.user.is_authenticated) @@ -129,6 +130,7 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): "archive", "comments", "document_comment", + "set_maximally_hidden", ): return _user_has_document_access(user, obj) diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index 6b86b9d1..a7954934 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -10,7 +10,11 @@ from django.http import QueryDict from django.contrib.auth import get_user_model from django.db.models import Q from django.utils.text import slugify -from documents.models import Document, DocumentAcknowledgement +from documents.models import ( + Document, + DocumentAcknowledgement, + DocumentDisplayState, +) from rest_framework import serializers from ..employees.serializers import EmployeeBriefSerializer @@ -300,6 +304,7 @@ class DocumentReadSerializer(serializers.ModelSerializer): source="file.size", read_only=True, allow_null=True ) is_acknowledged = serializers.SerializerMethodField() + is_maximally_hidden = serializers.SerializerMethodField() acknowledgement_required_for_user = serializers.SerializerMethodField() acknowledged_count = serializers.SerializerMethodField() acknowledgement_total = serializers.SerializerMethodField() @@ -338,6 +343,7 @@ class DocumentReadSerializer(serializers.ModelSerializer): "file_name", "file_size", "is_acknowledged", + "is_maximally_hidden", "linked_tasks", ) @@ -361,6 +367,21 @@ class DocumentReadSerializer(serializers.ModelSerializer): document=obj, user=request.user ).exists() + def get_is_maximally_hidden(self, obj: Document) -> bool: + """Максимально ли скрыта карточка для текущего пользователя.""" + annotated = getattr(obj, "_is_maximally_hidden", None) + if annotated is not None: + return bool(annotated) + + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return False + return DocumentDisplayState.objects.filter( + document=obj, + user=request.user, + is_maximally_hidden=True, + ).exists() + def get_acknowledgement_required_for_user(self, obj: Document) -> bool: """Нужно ли текущему сотруднику подтверждать ознакомление.""" request = self.context.get("request") diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index e37bafa1..6e06fcf1 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -10,9 +10,11 @@ from django.contrib.contenttypes.models import ContentType from django.db import transaction from django.db.models import Count, Exists, OuterRef, QuerySet, Q from django.http import FileResponse +from django.utils import timezone from documents.models import ( Document, DocumentAcknowledgement, + DocumentDisplayState, DocumentTag, ) from documents.audience import ( @@ -155,7 +157,15 @@ class DocumentViewSet(ModelViewSet): subq = DocumentAcknowledgement.objects.filter( document=OuterRef("pk"), user=user ) - qs = qs.annotate(_is_acknowledged=Exists(subq)) + hidden_subq = DocumentDisplayState.objects.filter( + document=OuterRef("pk"), + user=user, + is_maximally_hidden=True, + ) + qs = qs.annotate( + _is_acknowledged=Exists(subq), + _is_maximally_hidden=Exists(hidden_subq), + ) return qs.order_by("-uploaded_at") @@ -524,6 +534,54 @@ class DocumentViewSet(ModelViewSet): ) return Response({"ok": True, "already": not created}) + @action(methods=["post"], detail=True, url_path="set-maximally-hidden") + def set_maximally_hidden(self, request, pk=None): + """Переключить персональное максимальное скрытие карточки регламента.""" + document = self.get_object() + if not document.is_regulation: + return Response( + {"detail": "Максимальное скрытие доступно только для регламентов."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + raw_value = request.data.get("is_maximally_hidden", True) + + if isinstance(raw_value, bool): + is_maximally_hidden = raw_value + elif isinstance(raw_value, str): + normalized = raw_value.strip().lower() + if normalized in {"true", "1", "yes", "on"}: + is_maximally_hidden = True + elif normalized in {"false", "0", "no", "off"}: + is_maximally_hidden = False + else: + return Response( + {"is_maximally_hidden": "Ожидается булево значение."}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return Response( + {"is_maximally_hidden": "Ожидается булево значение."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + display_state, _ = DocumentDisplayState.objects.get_or_create( + document=document, + user=request.user, + defaults={"is_maximally_hidden": is_maximally_hidden}, + ) + if display_state.is_maximally_hidden != is_maximally_hidden: + display_state.is_maximally_hidden = is_maximally_hidden + display_state.updated_at = timezone.now() + display_state.save( + update_fields=["is_maximally_hidden", "updated_at"], + ) + + document._is_maximally_hidden = is_maximally_hidden + self._attach_linked_task_payloads([document]) + self._attach_document_metrics([document]) + return Response(self.get_serializer(document).data) + def _document_comment_payload(self, document, message): """Формат комментария документа для frontend.""" return { diff --git a/backend/documents/migrations/0018_document_display_state.py b/backend/documents/migrations/0018_document_display_state.py new file mode 100644 index 00000000..caaae4e6 --- /dev/null +++ b/backend/documents/migrations/0018_document_display_state.py @@ -0,0 +1,70 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0017_document_acknowledgement_audience"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DocumentDisplayState", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "is_maximally_hidden", + models.BooleanField( + default=True, + verbose_name="Максимально скрыт", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + verbose_name="Обновлено", + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="display_states", + to="documents.document", + verbose_name="Документ", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="document_display_states", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ], + options={ + "verbose_name": "Настройка отображения документа", + "verbose_name_plural": "Настройки отображения документов", + }, + ), + migrations.AddConstraint( + model_name="documentdisplaystate", + constraint=models.UniqueConstraint( + fields=("document", "user"), + name="unique_document_display_state_user", + ), + ), + ] diff --git a/backend/documents/models.py b/backend/documents/models.py index 6c97af49..25da2d0b 100644 --- a/backend/documents/models.py +++ b/backend/documents/models.py @@ -300,6 +300,44 @@ class DocumentAcknowledgement(models.Model): ) +class DocumentDisplayState(models.Model): + """Персональные настройки отображения документа в списках.""" + + document = models.ForeignKey( + Document, + verbose_name=_("Документ"), + on_delete=models.CASCADE, + related_name="display_states", + ) + user = models.ForeignKey( + User, + verbose_name=_("Сотрудник"), + on_delete=models.CASCADE, + related_name="document_display_states", + ) + is_maximally_hidden = models.BooleanField( + _("Максимально скрыт"), + default=True, + ) + updated_at = models.DateTimeField(_("Обновлено"), auto_now=True) + + class Meta: + verbose_name = _("Настройка отображения документа") + verbose_name_plural = _("Настройки отображения документов") + constraints = [ + models.UniqueConstraint( + fields=("document", "user"), + name="unique_document_display_state_user", + ), + ] + + def __str__(self): + return ( + f"{self.document_id}: {self.user_id} " + f"hidden={self.is_maximally_hidden}" + ) + + # ============================================================================ # ТИПЫ ДОКУМЕНТОВ И МЕТАДАННЫЕ # ============================================================================ diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index 14d87c9f..6cbdf00a 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -28,7 +28,12 @@ from django.contrib.auth.models import Permission from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from django.utils import timezone -from documents.models import Document, DocumentAcknowledgement, DocumentTag +from documents.models import ( + Document, + DocumentAcknowledgement, + DocumentDisplayState, + DocumentTag, +) from employees.models import Department, EmployeeDepartment from filer.models import File as FilerFile, Folder from rest_framework import status @@ -176,6 +181,9 @@ def api_urls(): def _ack(pk: int) -> str: return reverse("api:v1:documents-acknowledge", args=[pk]) + def _set_maximally_hidden(pk: int) -> str: + return reverse("api:v1:documents-set-maximally-hidden", args=[pk]) + def _acknowledgements(pk: int) -> str: return reverse("api:v1:documents-acknowledgements", args=[pk]) @@ -199,6 +207,7 @@ def api_urls(): "archive": reverse("api:v1:documents-archive"), "detail": _detail, "ack": _ack, + "set_maximally_hidden": _set_maximally_hidden, "acknowledgements": _acknowledgements, "comments": _comments, "comment_detail": _comment_detail, @@ -1466,6 +1475,57 @@ class TestAcknowledge: ] == 0 +class TestDocumentDisplayState: + """Персональный режим максимального скрытия карточки.""" + + def test_set_maximally_hidden_is_persisted_for_current_user( + self, auth_client, make_user, api_urls + ): + author = make_user("display-state-author@example.com") + viewer = make_user("display-state-viewer@example.com") + other_viewer = make_user("display-state-other-viewer@example.com") + document = make_document( + uploaded_by=author, + sent_to_all=True, + is_regulation=True, + ) + client = auth_client(viewer) + + initial = client.get(api_urls["detail"](document.id)) + assert initial.status_code == status.HTTP_200_OK + assert initial.json()["is_maximally_hidden"] is False + + hidden = client.post( + api_urls["set_maximally_hidden"](document.id), + {"is_maximally_hidden": True}, + format="json", + ) + assert hidden.status_code == status.HTTP_200_OK + assert hidden.json()["is_maximally_hidden"] is True + assert DocumentDisplayState.objects.filter( + document=document, + user=viewer, + is_maximally_hidden=True, + ).exists() + other_viewer_detail = auth_client(other_viewer).get( + api_urls["detail"](document.id) + ) + assert other_viewer_detail.status_code == status.HTTP_200_OK + assert other_viewer_detail.json()["is_maximally_hidden"] is False + assert not DocumentDisplayState.objects.filter( + document=document, + user=other_viewer, + ).exists() + + shown = client.post( + api_urls["set_maximally_hidden"](document.id), + {"is_maximally_hidden": False}, + format="json", + ) + assert shown.status_code == status.HTTP_200_OK + assert shown.json()["is_maximally_hidden"] is False + + class TestAcknowledgementsReport: """Ведомость ознакомлений доступна всем, кому доступен документ.""" diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index 8fa13a86..9da60be5 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -12,6 +12,7 @@ import { FileText, Plus, Eye, + EyeOff, X, FolderOpen, Tags, @@ -204,6 +205,7 @@ function DocumentsPageContent() { const [editingFolder, setEditingFolder] = useState(null); const [downloadingFolderId, setDownloadingFolderId] = useState(null); const [acknowledgingDocumentId, setAcknowledgingDocumentId] = useState(null); + const [maximallyHidingDocumentId, setMaximallyHidingDocumentId] = useState(null); const [selectionMode, setSelectionMode] = useState(false); // Bulk selection @@ -766,6 +768,34 @@ function DocumentsPageContent() { } }; + const handleToggleDocumentMaximallyHidden = async ( + documentItem: Document, + isMaximallyHidden: boolean, + ) => { + setMaximallyHidingDocumentId(documentItem.id); + setDocumentMenuOpenId(null); + + try { + const updatedDocument = await apiClient.setDocumentMaximallyHidden( + documentItem.id, + isMaximallyHidden, + ) as Document; + setDocuments((currentDocuments) => currentDocuments.map((document) => ( + document.id === documentItem.id + ? { + ...document, + is_maximally_hidden: updatedDocument.is_maximally_hidden, + } + : document + ))); + } catch (error) { + console.error("Не удалось изменить режим отображения регламента:", error); + toast.error("Не удалось изменить режим отображения регламента"); + } finally { + setMaximallyHidingDocumentId(null); + } + }; + const downloadFolderArchive = async (folder: FolderNode) => { setDownloadingFolderId(folder.id); @@ -1562,8 +1592,71 @@ function DocumentsPageContent() { const departmentsCount = doc.departments?.length || 0; const hasPreview = Boolean(doc.file_url && canPreviewDocument(doc.file_name || doc.title)); const isAcknowledging = acknowledgingDocumentId === doc.id; + const isChangingMaximumHiding = maximallyHidingDocumentId === doc.id; const acknowledgementRequiredForUser = doc.acknowledgement_required_for_user ?? doc.acknowledgement_required; + const isMaximallyHidden = ( + activeSection === "regulations" + && Boolean(doc.is_maximally_hidden) + ); + + if (isMaximallyHidden) { + return ( +
+
+ {selectionMode ? ( + toggleDocumentSelectionFromCheckbox(doc.id)} + className="h-4 w-4 shrink-0 rounded accent-[var(--accent-primary)]" + aria-label={`Выбрать регламент ${doc.title}`} + /> + ) : null} + + {documentIndex + 1} + + + +
+
+ ); + } // DEBUG: Проверка данных документа if (!authorName || !createdDate) { @@ -1929,7 +2022,23 @@ function DocumentsPageContent() { ) : null} -
+
+ {activeSection === "regulations" ? ( + + ) : null} {hasPreview ? (