feat(regulations): add personal compact display mode

This commit is contained in:
lizerk 2026-07-22 23:03:11 +03:00
parent dbf05700e9
commit 6c52c1857f
9 changed files with 369 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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}"
)
# ============================================================================
# ТИПЫ ДОКУМЕНТОВ И МЕТАДАННЫЕ
# ============================================================================

View File

@ -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:
"""Ведомость ознакомлений доступна всем, кому доступен документ."""

View File

@ -12,6 +12,7 @@ import {
FileText,
Plus,
Eye,
EyeOff,
X,
FolderOpen,
Tags,
@ -204,6 +205,7 @@ function DocumentsPageContent() {
const [editingFolder, setEditingFolder] = useState<FolderNode | null>(null);
const [downloadingFolderId, setDownloadingFolderId] = useState<number | null>(null);
const [acknowledgingDocumentId, setAcknowledgingDocumentId] = useState<number | null>(null);
const [maximallyHidingDocumentId, setMaximallyHidingDocumentId] = useState<number | null>(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 (
<article
key={doc.id}
className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]"
>
<div className="flex min-w-0 items-center gap-3 px-4 py-2.5">
{selectionMode ? (
<input
type="checkbox"
checked={isDocumentSelected}
onChange={() => toggleDocumentSelectionFromCheckbox(doc.id)}
className="h-4 w-4 shrink-0 rounded accent-[var(--accent-primary)]"
aria-label={`Выбрать регламент ${doc.title}`}
/>
) : null}
<span
className="app-selected app-accent-text inline-flex h-8 min-w-8 shrink-0 items-center justify-center rounded-full px-2 text-xs font-semibold tabular-nums"
aria-label={`Регламент ${documentIndex + 1} из ${filteredDocuments.length}`}
>
{documentIndex + 1}
</span>
<button
type="button"
onClick={() => setSelectedDocument(doc)}
className="min-w-0 flex-1 text-left"
>
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
{doc.title}
</span>
<span className="app-text-muted mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{createdDate ? <span>{createdDate}</span> : null}
{acknowledgementRequiredForUser ? (
<span className={`${doc.is_acknowledged ? "app-feedback-success" : "app-feedback-warning"} inline-flex rounded-full px-2 py-0.5`}>
{doc.is_acknowledged ? "Ознакомлен" : "Требует ознакомления"}
</span>
) : null}
</span>
</button>
<button
type="button"
onClick={() => void handleToggleDocumentMaximallyHidden(doc, false)}
disabled={isChangingMaximumHiding}
className="app-selected app-accent-text inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-60"
title="Показать регламент полностью"
aria-label={`Показать регламент ${doc.title} полностью`}
>
{isChangingMaximumHiding ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Eye size={15} />
)}
</button>
</div>
</article>
);
}
// DEBUG: Проверка данных документа
if (!authorName || !createdDate) {
@ -1929,7 +2022,23 @@ function DocumentsPageContent() {
</button>
) : null}
<div className="flex min-w-0 flex-nowrap items-center justify-end gap-1.5">
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
{activeSection === "regulations" ? (
<button
type="button"
onClick={() => void handleToggleDocumentMaximallyHidden(doc, true)}
disabled={isChangingMaximumHiding}
title="Максимально скрыть"
aria-label={`Максимально скрыть регламент ${doc.title}`}
className="app-action-secondary inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-60"
>
{isChangingMaximumHiding ? (
<Loader2 size={16} className="animate-spin" />
) : (
<EyeOff size={16} />
)}
</button>
) : null}
{hasPreview ? (
<button
type="button"

View File

@ -112,6 +112,12 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re
);
},
acknowledgeDocument: (id: number) => request(`/api/v1/documents/${id}/acknowledge/`, { method: 'POST', body: JSON.stringify({}) }),
setDocumentMaximallyHidden: (id: number, isMaximallyHidden: boolean) =>
request(`/api/v1/documents/${id}/set-maximally-hidden/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_maximally_hidden: isMaximallyHidden }),
}),
getDocumentAcknowledgements: (id: number, search?: string) => {
const qp = new URLSearchParams();
if (search) qp.append('search', search);

View File

@ -704,6 +704,7 @@ export interface Document {
acknowledgement_recipients?: User[];
acknowledgement_departments?: { id: number; name: string }[];
is_acknowledged?: boolean;
is_maximally_hidden?: boolean;
acknowledged_count?: number;
acknowledgement_total?: number;
comments_count?: number;