From 042952b7a9b9befd48472baf589288c1aa5592e8 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 6 Jul 2026 15:24:37 +0300 Subject: [PATCH] feat: link tasks with workplace entities --- backend/api/v1/documents/serializers.py | 56 ++ backend/api/v1/documents/views.py | 67 ++ backend/api/v1/requests_app/serializers.py | 57 ++ backend/api/v1/requests_app/views.py | 69 ++ backend/api/v1/tasks/serializers.py | 179 ++++++ backend/api/v1/tasks/views.py | 479 +++++++++++++- backend/employees/utils.py | 51 ++ backend/tasks/access.py | 44 ++ .../migrations/0005_add_document_link_kind.py | 40 ++ .../migrations/0006_add_request_link_kind.py | 42 ++ backend/tasks/models.py | 2 + .../api/v1/documents/test_documents_api.py | 91 +++ .../api/v1/requests_app/test_requests_api.py | 97 +++ backend/tests/api/v1/tasks/test_tasks_api.py | 279 ++++++++ frontend/src/app/documents/page.tsx | 47 ++ frontend/src/app/messages/[chatId]/page.tsx | 81 ++- frontend/src/app/requests/page.tsx | 17 +- frontend/src/app/tasks/page.tsx | 596 ++++++++++++++++-- .../src/components/ViewEventDetailsModal.tsx | 74 ++- .../documents/DocumentDetailModal.tsx | 31 +- .../documents/DocumentTaskLinks.tsx | 571 +++++++++++++++++ .../requests/RequestDetailModal.tsx | 40 +- .../components/requests/RequestListItem.tsx | 38 +- .../requests/RequestListSection.tsx | 3 + .../components/requests/RequestTaskLinks.tsx | 576 +++++++++++++++++ frontend/src/hooks/useRequestsPage.ts | 14 + frontend/src/lib/api/tasks.ts | 40 ++ frontend/src/types/api.ts | 51 +- 28 files changed, 3627 insertions(+), 105 deletions(-) create mode 100644 backend/tasks/migrations/0005_add_document_link_kind.py create mode 100644 backend/tasks/migrations/0006_add_request_link_kind.py create mode 100644 frontend/src/components/documents/DocumentTaskLinks.tsx create mode 100644 frontend/src/components/requests/RequestTaskLinks.tsx diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index 19b18b97..ab7b9c8c 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -293,6 +293,7 @@ class DocumentReadSerializer(serializers.ModelSerializer): source="file.size", read_only=True, allow_null=True ) is_acknowledged = serializers.SerializerMethodField() + linked_tasks = serializers.SerializerMethodField() class Meta: model = Document @@ -319,6 +320,7 @@ class DocumentReadSerializer(serializers.ModelSerializer): "file_name", "file_size", "is_acknowledged", + "linked_tasks", ) def get_is_acknowledged(self, obj: Document) -> bool: @@ -341,6 +343,60 @@ class DocumentReadSerializer(serializers.ModelSerializer): document=obj, user=request.user ).exists() + @extend_schema_field(serializers.ListField(child=serializers.DictField())) + def get_linked_tasks(self, obj: Document) -> list[dict]: + prefetched = getattr(obj, "_linked_task_payloads", None) + if prefetched is not None: + return prefetched + + request = self.context.get("request") + user = getattr(request, "user", None) + if not user or not getattr(user, "is_authenticated", False): + return [] + + try: + from django.contrib.contenttypes.models import ContentType + from tasks.access import task_board_access_q + from tasks.models import ( + TaskBoard, + TaskLinkedObject, + TaskLinkedObjectKind, + ) + except Exception: + return [] + + content_type = ContentType.objects.get_for_model(Document) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=TaskLinkedObjectKind.DOCUMENT, + content_type=content_type, + object_id=obj.id, + task__board__in=accessible_boards, + ) + .select_related("task", "task__board", "task__column") + .order_by("task__title", "task_id") + ) + + return [ + { + "link_id": link.id, + "id": link.task_id, + "title": link.task.title, + "board_id": link.task.board_id, + "board_name": link.task.board.name, + "column_id": link.task.column_id, + "column_name": link.task.column.name, + "column_color": link.task.column.color, + "priority": link.task.priority, + "priority_display": link.task.get_priority_display(), + } + for link in links + ] + class DocumentWriteSerializer(serializers.ModelSerializer): """Сериализатор для записи/обновления документа. diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index a2eddb81..40671d14 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -6,6 +6,7 @@ import posixpath import zipfile from django.contrib.auth import get_user_model +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 @@ -149,6 +150,72 @@ class DocumentViewSet(ModelViewSet): return qs.order_by("-uploaded_at") + def _attach_linked_task_payloads(self, documents: list[Document]) -> None: + """Предзагрузить компактные бейджи задач для списка документов.""" + if not documents: + return + + user = getattr(self.request, "user", None) + document_ids = [document.id for document in documents] + mapping = {document_id: [] for document_id in document_ids} + + if user and user.is_authenticated: + from tasks.access import task_board_access_q + from tasks.models import ( + TaskBoard, + TaskLinkedObject, + TaskLinkedObjectKind, + ) + + document_ct = ContentType.objects.get_for_model(Document) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=TaskLinkedObjectKind.DOCUMENT, + content_type=document_ct, + object_id__in=document_ids, + task__board__in=accessible_boards, + ) + .select_related("task", "task__board", "task__column") + .order_by("object_id", "task__title", "task_id") + ) + + for link in links: + mapping.setdefault(link.object_id, []).append( + { + "link_id": link.id, + "id": link.task_id, + "title": link.task.title, + "board_id": link.task.board_id, + "board_name": link.task.board.name, + "column_id": link.task.column_id, + "column_name": link.task.column.name, + "column_color": link.task.column.color, + "priority": link.task.priority, + "priority_display": link.task.get_priority_display(), + } + ) + + for document in documents: + document._linked_task_payloads = mapping.get(document.id, []) + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + self._attach_linked_task_payloads(list(page)) + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + documents = list(queryset) + self._attach_linked_task_payloads(documents) + serializer = self.get_serializer(documents, many=True) + return Response(serializer.data) + @transaction.atomic def create(self, request, *args, **kwargs): """Создаёт документ и возвращает read-схему + recipient_count. diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index 01f0ba27..317e8eb7 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -96,6 +96,7 @@ class RequestReadSerializer(serializers.ModelSerializer): can_decide = serializers.SerializerMethodField() # Используем аннотированное поле из queryset comments_count = serializers.IntegerField(read_only=True, allow_null=True) + linked_tasks = serializers.SerializerMethodField() class Meta: model = Request @@ -127,6 +128,7 @@ class RequestReadSerializer(serializers.ModelSerializer): "is_recipient", "can_decide", "comments_count", + "linked_tasks", ) read_only_fields = ( "employee", @@ -145,6 +147,7 @@ class RequestReadSerializer(serializers.ModelSerializer): "is_recipient", "can_decide", "comments_count", + "linked_tasks", ) @extend_schema_field(serializers.IntegerField()) @@ -192,6 +195,60 @@ class RequestReadSerializer(serializers.ModelSerializer): return False return obj.recipients.filter(id=user.id).exists() + @extend_schema_field(serializers.ListField(child=serializers.DictField())) + def get_linked_tasks(self, obj: Request) -> list[dict]: + prefetched = getattr(obj, "_linked_task_payloads", None) + if prefetched is not None: + return prefetched + + request = self.context.get("request") + user = getattr(request, "user", None) + if not user or not getattr(user, "is_authenticated", False): + return [] + + try: + from django.contrib.contenttypes.models import ContentType + from tasks.access import task_board_access_q + from tasks.models import ( + TaskBoard, + TaskLinkedObject, + TaskLinkedObjectKind, + ) + except Exception: + return [] + + content_type = ContentType.objects.get_for_model(Request) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=TaskLinkedObjectKind.REQUEST, + content_type=content_type, + object_id=obj.id, + task__board__in=accessible_boards, + ) + .select_related("task", "task__board", "task__column") + .order_by("task__title", "task_id") + ) + + return [ + { + "link_id": link.id, + "id": link.task_id, + "title": link.task.title, + "board_id": link.task.board_id, + "board_name": link.task.board.name, + "column_id": link.task.column_id, + "column_name": link.task.column.name, + "column_color": link.task.column.color, + "priority": link.task.priority, + "priority_display": link.task.get_priority_display(), + } + for link in links + ] + class RequestWriteSerializer(serializers.ModelSerializer): """Сериализатор записи заявки. diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index 86d9bcf1..4a165da3 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -379,6 +379,75 @@ class RequestViewSet(viewsets.ModelViewSet): return qs + def _attach_linked_task_payloads( + self, + requests: list[EmployeeRequest], + ) -> None: + """Предзагрузить компактные бейджи задач для списка заявлений.""" + if not requests: + return + + user = getattr(self.request, "user", None) + request_ids = [request_obj.id for request_obj in requests] + mapping = {request_id: [] for request_id in request_ids} + + if user and user.is_authenticated: + from tasks.access import task_board_access_q + from tasks.models import ( + TaskBoard, + TaskLinkedObject, + TaskLinkedObjectKind, + ) + + request_ct = ContentType.objects.get_for_model(EmployeeRequest) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=TaskLinkedObjectKind.REQUEST, + content_type=request_ct, + object_id__in=request_ids, + task__board__in=accessible_boards, + ) + .select_related("task", "task__board", "task__column") + .order_by("object_id", "task__title", "task_id") + ) + + for link in links: + mapping.setdefault(link.object_id, []).append( + { + "link_id": link.id, + "id": link.task_id, + "title": link.task.title, + "board_id": link.task.board_id, + "board_name": link.task.board.name, + "column_id": link.task.column_id, + "column_name": link.task.column.name, + "column_color": link.task.column.color, + "priority": link.task.priority, + "priority_display": link.task.get_priority_display(), + } + ) + + for request_obj in requests: + request_obj._linked_task_payloads = mapping.get(request_obj.id, []) + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + self._attach_linked_task_payloads(list(page)) + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + requests = list(queryset) + self._attach_linked_task_payloads(requests) + serializer = self.get_serializer(requests, many=True) + return Response(serializer.data) + # --- ВАЖНО: не ловить 404 на detail-экшенах # из-за урезанного get_queryset() --- diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 88d6db71..6195a8b9 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -4,16 +4,22 @@ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from api.v1.documents.serializers import DocumentReadSerializer from api.v1.employees.serializers import EmployeeBriefSerializer from api.v1.employees.serializers.department import DepartmentBriefSerializer from communications.models import Message +from communications.comments_helpers import get_comment_count from communications.serialization import serialize_message from communications.utils import user_can_access_chat +from documents.models import Document from employees.models import Department +from requests_app.models import Request as EmployeeRequest from schedule.models import Event from api.v1.schedule.serializers import EventSerializer from tasks.access import ( user_can_access_calendar_event, + user_can_access_document, + user_can_access_employee_request, user_can_access_task_board, ) from tasks.models import ( @@ -134,7 +140,10 @@ class TaskSerializer(serializers.ModelSerializer): column_color = serializers.CharField(source="column.color", read_only=True) linked_messages_count = serializers.SerializerMethodField() linked_events_count = serializers.SerializerMethodField() + linked_documents_count = serializers.SerializerMethodField() + linked_requests_count = serializers.SerializerMethodField() linked_objects_count = serializers.SerializerMethodField() + comments_count = serializers.SerializerMethodField() class Meta: model = Task @@ -159,7 +168,10 @@ class TaskSerializer(serializers.ModelSerializer): "completed_at", "linked_messages_count", "linked_events_count", + "linked_documents_count", + "linked_requests_count", "linked_objects_count", + "comments_count", "created_at", "updated_at", ] @@ -173,7 +185,10 @@ class TaskSerializer(serializers.ModelSerializer): "completed_at", "linked_messages_count", "linked_events_count", + "linked_documents_count", + "linked_requests_count", "linked_objects_count", + "comments_count", "created_at", "updated_at", ] @@ -194,12 +209,34 @@ class TaskSerializer(serializers.ModelSerializer): kind=TaskLinkedObjectKind.CALENDAR_EVENT, ).count() + @extend_schema_field(serializers.IntegerField()) + def get_linked_documents_count(self, obj): + if hasattr(obj, "linked_documents_count"): + return obj.linked_documents_count + return obj.linked_objects.filter( + kind=TaskLinkedObjectKind.DOCUMENT, + ).count() + + @extend_schema_field(serializers.IntegerField()) + def get_linked_requests_count(self, obj): + if hasattr(obj, "linked_requests_count"): + return obj.linked_requests_count + return obj.linked_objects.filter( + kind=TaskLinkedObjectKind.REQUEST, + ).count() + @extend_schema_field(serializers.IntegerField()) def get_linked_objects_count(self, obj): if hasattr(obj, "linked_objects_count"): return obj.linked_objects_count return obj.linked_objects.count() + @extend_schema_field(serializers.IntegerField()) + def get_comments_count(self, obj): + if hasattr(obj, "comments_count"): + return obj.comments_count + return get_comment_count(obj) + def validate(self, attrs): request = self.context.get("request") user = getattr(request, "user", None) @@ -427,3 +464,145 @@ class TaskLinkedCalendarEventSerializer(serializers.ModelSerializer): def get_calendar_event_content_type(): return ContentType.objects.get_for_model(Event) + + +class TaskLinkedDocumentSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + document = serializers.SerializerMethodField() + document_id = serializers.IntegerField(source="object_id", read_only=True) + can_open = serializers.SerializerMethodField() + object_url = serializers.SerializerMethodField() + + class Meta: + model = TaskLinkedObject + fields = [ + "id", + "kind", + "document_id", + "document", + "can_open", + "object_url", + "created_by", + "created_at", + ] + read_only_fields = fields + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_document(self, obj): + if obj.kind != TaskLinkedObjectKind.DOCUMENT: + return None + + document = getattr(obj, "content_object", None) + if document is None: + return None + + if not self.get_can_open(obj): + return { + "id": document.id, + "title": document.title, + "description": document.description, + "is_regulation": document.is_regulation, + "folder_path": document.folder_path, + "file_name": ( + getattr(document.file, "name", None) + if document.file + else None + ), + } + + return DocumentReadSerializer(document, context=self.context).data + + @extend_schema_field(serializers.BooleanField()) + def get_can_open(self, obj): + document = getattr(obj, "content_object", None) + request = self.context.get("request") + user = getattr(request, "user", None) + if ( + obj.kind != TaskLinkedObjectKind.DOCUMENT + or document is None + or not user + ): + return False + return user_can_access_document(user, document) + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_object_url(self, obj): + document = getattr(obj, "content_object", None) + if document is None or not self.get_can_open(obj): + return None + return f"/documents?document={document.id}" + + +def get_document_content_type(): + return ContentType.objects.get_for_model(Document) + + +class TaskLinkedRequestSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + request = serializers.SerializerMethodField() + request_id = serializers.IntegerField(source="object_id", read_only=True) + can_open = serializers.SerializerMethodField() + object_url = serializers.SerializerMethodField() + + class Meta: + model = TaskLinkedObject + fields = [ + "id", + "kind", + "request_id", + "request", + "can_open", + "object_url", + "created_by", + "created_at", + ] + read_only_fields = fields + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_request(self, obj): + if obj.kind != TaskLinkedObjectKind.REQUEST: + return None + + request_obj = getattr(obj, "content_object", None) + if request_obj is None: + return None + + return { + "id": request_obj.id, + "title": request_obj.title, + "display_title": request_obj.display_title, + "type": request_obj.type, + "type_display": request_obj.get_type_display(), + "status": request_obj.status, + "status_display": request_obj.get_status_display(), + "comment": request_obj.comment, + "date_from": request_obj.date_from, + "date_to": request_obj.date_to, + "employee": EmployeeBriefSerializer(request_obj.employee).data, + "created_at": request_obj.created_at, + "updated_at": request_obj.updated_at, + } + + @extend_schema_field(serializers.BooleanField()) + def get_can_open(self, obj): + request_obj = getattr(obj, "content_object", None) + request = self.context.get("request") + user = getattr(request, "user", None) + if ( + obj.kind != TaskLinkedObjectKind.REQUEST + or request_obj is None + or not user + ): + return False + return user_can_access_employee_request(user, request_obj) + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_object_url(self, obj): + request_obj = getattr(obj, "content_object", None) + if request_obj is None or not self.get_can_open(obj): + return None + return f"/requests?request={request_obj.id}" + + +def get_request_content_type(): + return ContentType.objects.get_for_model(EmployeeRequest) diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 27b12953..98de4f30 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -1,13 +1,31 @@ -from django.db.models import Count, Max, Q +from django.contrib.contenttypes.models import ContentType +from django.db.models import ( + Count, + IntegerField, + Max, + OuterRef, + Q, + Subquery, + Value, +) +from django.db.models.functions import Coalesce +from communications import comments_helpers from communications.models import Message from communications.utils import user_can_access_chat +from documents.models import Document +from requests_app.models import Request as EmployeeRequest from rest_framework import filters, status, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from schedule.models import Event -from tasks.access import task_board_access_q, user_can_access_calendar_event +from tasks.access import ( + task_board_access_q, + user_can_access_calendar_event, + user_can_access_document, + user_can_access_employee_request, +) from tasks.models import ( Task, TaskActivity, @@ -24,16 +42,21 @@ from tasks.realtime import ( ) from .serializers import ( + EmployeeBriefSerializer, TaskActivitySerializer, TaskBoardSerializer, TaskColumnSerializer, TaskLabelSerializer, TaskLinkedCalendarEventSerializer, + TaskLinkedDocumentSerializer, TaskLinkedMessageSerializer, + TaskLinkedRequestSerializer, TaskSerializer, create_default_columns, get_calendar_event_content_type, + get_document_content_type, get_message_content_type, + get_request_content_type, ) @@ -215,6 +238,18 @@ class TaskViewSet(viewsets.ModelViewSet): ordering = ["position", "-created_at"] def get_queryset(self): + task_content_type = ContentType.objects.get_for_model(Task) + comments_subquery = ( + Message.objects.filter( + chat__type="comments", + chat__context_content_type=task_content_type, + chat__context_object_id=OuterRef("pk"), + is_deleted=False, + ) + .values("chat") + .annotate(count=Count("id")) + .values("count") + ) queryset = ( Task.objects.filter(board__is_archived=False) .filter( @@ -237,7 +272,25 @@ class TaskViewSet(viewsets.ModelViewSet): ), distinct=True, ), + linked_documents_count=Count( + "linked_objects", + filter=Q( + linked_objects__kind=TaskLinkedObjectKind.DOCUMENT, + ), + distinct=True, + ), + linked_requests_count=Count( + "linked_objects", + filter=Q( + linked_objects__kind=TaskLinkedObjectKind.REQUEST, + ), + distinct=True, + ), linked_objects_count=Count("linked_objects", distinct=True), + comments_count=Coalesce( + Subquery(comments_subquery, output_field=IntegerField()), + Value(0), + ), ) ) params = self.request.query_params @@ -374,10 +427,106 @@ class TaskViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["get"], url_path="activity") def activity(self, request, pk=None): task = self.get_object() - queryset = task.activities.select_related("actor").order_by("-created_at", "-id") + queryset = task.activities.select_related("actor").order_by( + "-created_at", + "-id", + ) serializer = TaskActivitySerializer(queryset, many=True) return Response(serializer.data) + def _comment_payload(self, task, message): + return { + "id": message.id, + "task": task.id, + "author": EmployeeBriefSerializer(message.author).data, + "text": message.content, + "created_at": message.created_at, + } + + @action(detail=True, methods=["get", "post"], url_path="comments") + def comments(self, request, pk=None): + task = self.get_object() + + if request.method in ("GET", "HEAD"): + messages = comments_helpers.get_comments(task) + return Response( + [self._comment_payload(task, message) for message in messages] + ) + + text = request.data.get("text", "").strip() + if not text: + return Response( + {"text": ["Это поле не может быть пустым."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + message = comments_helpers.create_comment( + obj=task, + author=request.user, + content=text, + ) + send_task_board_update( + task.board, + "commented", + "task", + task.id, + extra={"comment_id": message.id}, + ) + return Response( + self._comment_payload(task, message), + status=status.HTTP_201_CREATED, + ) + + @action( + detail=True, + methods=["delete"], + url_path=r"comments/(?P\d+)", + ) + def delete_comment(self, request, pk=None, comment_id=None): + task = self.get_object() + chat = comments_helpers.get_comments_chat_if_exists(task) + if chat is None: + return Response( + {"detail": "Комментарий не найден."}, + status=status.HTTP_404_NOT_FOUND, + ) + + try: + message = Message.objects.get( + id=comment_id, + chat=chat, + is_deleted=False, + ) + except Message.DoesNotExist: + return Response( + {"detail": "Комментарий не найден."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not ( + request.user.is_staff + or request.user.is_superuser + or message.author_id == request.user.id + ): + return Response( + {"detail": "Нет прав на удаление комментария."}, + status=status.HTTP_403_FORBIDDEN, + ) + + comments_helpers.delete_comment( + message=message, + deleted_by=request.user, + soft_delete=True, + ) + send_task_board_update( + task.board, + "comment_deleted", + "task", + task.id, + extra={"comment_id": int(comment_id)}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=False, methods=["get"], url_path="linked-event-tasks") def linked_event_tasks(self, request): event_id = request.query_params.get("event_id") @@ -421,6 +570,92 @@ class TaskViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + @action(detail=False, methods=["get"], url_path="linked-document-tasks") + def linked_document_tasks(self, request): + document_id = request.query_params.get("document_id") + try: + document_id = int(document_id) + except (TypeError, ValueError): + return Response( + {"document_id": ["Укажите ID документа."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + document = ( + Document.objects.select_related("uploaded_by", "file", "folder") + .prefetch_related("recipients", "departments", "tags") + .get(id=document_id) + ) + except Document.DoesNotExist: + return Response( + {"document_id": ["Документ не найден."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_document(request.user, document): + return Response( + {"detail": "Нет доступа к этому документу."}, + status=status.HTTP_403_FORBIDDEN, + ) + + queryset = ( + self.get_queryset() + .filter( + linked_objects__kind=TaskLinkedObjectKind.DOCUMENT, + linked_objects__content_type=get_document_content_type(), + linked_objects__object_id=document.id, + ) + .distinct() + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + @action(detail=False, methods=["get"], url_path="linked-request-tasks") + def linked_request_tasks(self, request): + request_id = request.query_params.get("request_id") + try: + request_id = int(request_id) + except (TypeError, ValueError): + return Response( + {"request_id": ["Укажите ID заявления."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + employee_request = ( + EmployeeRequest.objects.select_related( + "employee", + "approver", + "department", + ) + .prefetch_related("recipients", "cc_users") + .get(id=request_id) + ) + except EmployeeRequest.DoesNotExist: + return Response( + {"request_id": ["Заявление не найдено."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_employee_request(request.user, employee_request): + return Response( + {"detail": "Нет доступа к этому заявлению."}, + status=status.HTTP_403_FORBIDDEN, + ) + + queryset = ( + self.get_queryset() + .filter( + linked_objects__kind=TaskLinkedObjectKind.REQUEST, + linked_objects__content_type=get_request_content_type(), + linked_objects__object_id=employee_request.id, + ) + .distinct() + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + def _message_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( @@ -441,6 +676,26 @@ class TaskViewSet(viewsets.ModelViewSet): .order_by("-created_at", "-id") ) + def _document_links_queryset(self, task): + return ( + TaskLinkedObject.objects.filter( + task=task, + kind=TaskLinkedObjectKind.DOCUMENT, + ) + .select_related("created_by", "content_type") + .order_by("-created_at", "-id") + ) + + def _request_links_queryset(self, task): + return ( + TaskLinkedObject.objects.filter( + task=task, + kind=TaskLinkedObjectKind.REQUEST, + ) + .select_related("created_by", "content_type") + .order_by("-created_at", "-id") + ) + @action(detail=True, methods=["get", "post"], url_path="linked-messages") def linked_messages(self, request, pk=None): task = self.get_object() @@ -565,6 +820,224 @@ class TaskViewSet(viewsets.ModelViewSet): ) return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["get", "post"], url_path="linked-documents") + def linked_documents(self, request, pk=None): + task = self.get_object() + if request.method == "GET": + serializer = TaskLinkedDocumentSerializer( + self._document_links_queryset(task), + many=True, + context={"request": request}, + ) + return Response(serializer.data) + + document_id = request.data.get("document_id") + try: + document_id = int(document_id) + except (TypeError, ValueError): + return Response( + {"document_id": ["Укажите ID документа."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + document = ( + Document.objects.select_related("uploaded_by", "file", "folder") + .prefetch_related("recipients", "departments", "tags") + .get(id=document_id) + ) + except Document.DoesNotExist: + return Response( + {"document_id": ["Документ не найден."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_document(request.user, document): + return Response( + {"detail": "Нет доступа к этому документу."}, + status=status.HTTP_403_FORBIDDEN, + ) + + link, created = TaskLinkedObject.objects.get_or_create( + task=task, + kind=TaskLinkedObjectKind.DOCUMENT, + content_type=get_document_content_type(), + object_id=document.id, + defaults={"created_by": request.user}, + ) + serializer = TaskLinkedDocumentSerializer( + link, + context={"request": request}, + ) + if created: + create_task_activity( + task, + request.user, + TaskActivityAction.LINKED, + object_kind=TaskLinkedObjectKind.DOCUMENT, + object_id=document.id, + metadata={ + "object_label": document.title, + "object_type": "Документ", + }, + ) + send_task_board_update( + task.board, + "linked", + "document", + document.id, + extra={"task_id": task.id, "link_id": link.id}, + ) + return Response( + serializer.data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @action( + detail=True, + methods=["delete"], + url_path=r"linked-documents/(?P\d+)", + ) + def unlink_document(self, request, pk=None, link_id=None): + task = self.get_object() + try: + link = self._document_links_queryset(task).get(id=link_id) + except TaskLinkedObject.DoesNotExist: + return Response( + {"detail": "Связь не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ) + + document_id = link.object_id + link.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.UNLINKED, + object_kind=TaskLinkedObjectKind.DOCUMENT, + object_id=document_id, + metadata={"object_type": "Документ"}, + ) + send_task_board_update( + task.board, + "unlinked", + "document", + document_id, + extra={"task_id": task.id, "link_id": int(link_id)}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["get", "post"], url_path="linked-requests") + def linked_requests(self, request, pk=None): + task = self.get_object() + if request.method == "GET": + serializer = TaskLinkedRequestSerializer( + self._request_links_queryset(task), + many=True, + context={"request": request}, + ) + return Response(serializer.data) + + request_id = request.data.get("request_id") + try: + request_id = int(request_id) + except (TypeError, ValueError): + return Response( + {"request_id": ["Укажите ID заявления."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + employee_request = ( + EmployeeRequest.objects.select_related( + "employee", + "approver", + "department", + ) + .prefetch_related("recipients", "cc_users") + .get(id=request_id) + ) + except EmployeeRequest.DoesNotExist: + return Response( + {"request_id": ["Заявление не найдено."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_employee_request(request.user, employee_request): + return Response( + {"detail": "Нет доступа к этому заявлению."}, + status=status.HTTP_403_FORBIDDEN, + ) + + link, created = TaskLinkedObject.objects.get_or_create( + task=task, + kind=TaskLinkedObjectKind.REQUEST, + content_type=get_request_content_type(), + object_id=employee_request.id, + defaults={"created_by": request.user}, + ) + serializer = TaskLinkedRequestSerializer( + link, + context={"request": request}, + ) + if created: + create_task_activity( + task, + request.user, + TaskActivityAction.LINKED, + object_kind=TaskLinkedObjectKind.REQUEST, + object_id=employee_request.id, + metadata={ + "object_label": employee_request.display_title, + "object_type": "Заявление", + }, + ) + send_task_board_update( + task.board, + "linked", + "request", + employee_request.id, + extra={"task_id": task.id, "link_id": link.id}, + ) + return Response( + serializer.data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @action( + detail=True, + methods=["delete"], + url_path=r"linked-requests/(?P\d+)", + ) + def unlink_request(self, request, pk=None, link_id=None): + task = self.get_object() + try: + link = self._request_links_queryset(task).get(id=link_id) + except TaskLinkedObject.DoesNotExist: + return Response( + {"detail": "Связь не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ) + + request_id = link.object_id + link.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.UNLINKED, + object_kind=TaskLinkedObjectKind.REQUEST, + object_id=request_id, + metadata={"object_type": "Заявление"}, + ) + send_task_board_update( + task.board, + "unlinked", + "request", + request_id, + extra={"task_id": task.id, "link_id": int(link_id)}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["get", "post"], url_path="linked-events") def linked_events(self, request, pk=None): task = self.get_object() diff --git a/backend/employees/utils.py b/backend/employees/utils.py index f27ffe96..9045446f 100644 --- a/backend/employees/utils.py +++ b/backend/employees/utils.py @@ -425,6 +425,50 @@ def resolve_chat_participants_for_procurement_item(context_object, **kwargs): ) +def resolve_chat_participants_for_task(context_object, **kwargs): + """ + EUSRR callback для участников комментариев задачи. + + Доступ соответствует видимости доски задачи. + """ + from django.db.models import Q + from tasks.models import Task + + if not isinstance(context_object, Task): + return Employee.objects.none() + + board = context_object.board + if not board.members.exists() and not board.departments.exists(): + return Employee.objects.filter(is_active=True) + + department_ids = list(board.departments.values_list("id", flat=True)) + member_ids = board.members.values_list("id", flat=True) + department_member_ids = EmployeeDepartment.objects.filter( + department_id__in=department_ids, + is_active=True, + ).values_list("employee_id", flat=True) + role_member_ids = RoleAssignment.objects.filter( + role__department_id__in=department_ids, + is_active=True, + ).values_list("employee_id", flat=True) + department_head_ids = Department.objects.filter( + id__in=department_ids, + head_id__isnull=False, + ).values_list("head_id", flat=True) + + query = ( + Q(id=board.created_by_id) + | Q(id__in=member_ids) + | Q(id__in=department_member_ids) + | Q(id__in=role_member_ids) + | Q(id__in=department_head_ids) + | Q(is_staff=True) + | Q(is_superuser=True) + ) + + return Employee.objects.filter(query, is_active=True).distinct() + + def resolve_chat_participants(chat): """ Главная функция для разрешения участников чата в EUSRR. @@ -471,6 +515,13 @@ def resolve_chat_participants(chat): chat.context_object, chat=chat, type=chat.type ) + from tasks.models import Task + + if isinstance(chat.context_object, Task): + return resolve_chat_participants_for_task( + chat.context_object, chat=chat, type=chat.type + ) + # 5. Legacy: department field (DEPRECATED, для обратной совместимости) if ( chat.type == "department" diff --git a/backend/tasks/access.py b/backend/tasks/access.py index 1bfd9dde..6ee167b8 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -1,5 +1,8 @@ from django.db.models import Q +from documents.models import Document from employees.models import Department, EmployeeDepartment, RoleAssignment +from requests_app.enums import RequestStatus +from requests_app.models import Request as EmployeeRequest from schedule.models import Event from scheduling.services import user_can_view_calendar @@ -63,3 +66,44 @@ def user_can_access_calendar_event(user, event: Event | None) -> bool: if not user or not user.is_authenticated or event is None: return False return user_can_view_calendar(user, event.calendar) + + +def user_can_access_document(user, document: Document | None) -> bool: + if not user or not user.is_authenticated or document is None: + return False + if user.is_staff or user.is_superuser: + return True + if ( + user.has_perm("documents.view_document") + or user.has_perm("documents.add_document") + or user.has_perm("documents.change_document") + or user.has_perm("documents.delete_document") + ): + return True + if document.sent_to_all and user.is_active: + return True + if document.uploaded_by_id == user.id: + return True + if document.recipients.filter(id=user.id, is_active=True).exists(): + return True + return document.departments.filter( + employeedepartment__employee=user, + employeedepartment__is_active=True, + ).exists() + + +def user_can_access_employee_request( + user, + request_obj: EmployeeRequest | None, +) -> bool: + if not user or not user.is_authenticated or request_obj is None: + return False + + if request_obj.status == RequestStatus.DRAFT: + return request_obj.employee_id == user.id + + if request_obj.employee_id == user.id: + return True + if request_obj.recipients.filter(id=user.id).exists(): + return True + return request_obj.cc_users.filter(id=user.id).exists() diff --git a/backend/tasks/migrations/0005_add_document_link_kind.py b/backend/tasks/migrations/0005_add_document_link_kind.py new file mode 100644 index 00000000..a2e63428 --- /dev/null +++ b/backend/tasks/migrations/0005_add_document_link_kind.py @@ -0,0 +1,40 @@ +# Generated by Codex on 2026-07-06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tasks", "0004_taskactivity"), + ] + + operations = [ + migrations.AlterField( + model_name="taskactivity", + name="object_kind", + field=models.CharField( + blank=True, + choices=[ + ("message", "Сообщение"), + ("calendar_event", "Календарное событие"), + ("document", "Документ"), + ], + max_length=32, + verbose_name="Тип объекта", + ), + ), + migrations.AlterField( + model_name="tasklinkedobject", + name="kind", + field=models.CharField( + choices=[ + ("message", "Сообщение"), + ("calendar_event", "Календарное событие"), + ("document", "Документ"), + ], + max_length=32, + verbose_name="Тип связи", + ), + ), + ] diff --git a/backend/tasks/migrations/0006_add_request_link_kind.py b/backend/tasks/migrations/0006_add_request_link_kind.py new file mode 100644 index 00000000..54a547dc --- /dev/null +++ b/backend/tasks/migrations/0006_add_request_link_kind.py @@ -0,0 +1,42 @@ +# Generated by Codex on 2026-07-06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tasks", "0005_add_document_link_kind"), + ] + + operations = [ + migrations.AlterField( + model_name="taskactivity", + name="object_kind", + field=models.CharField( + blank=True, + choices=[ + ("message", "Сообщение"), + ("calendar_event", "Календарное событие"), + ("document", "Документ"), + ("request", "Заявление"), + ], + max_length=32, + verbose_name="Тип объекта", + ), + ), + migrations.AlterField( + model_name="tasklinkedobject", + name="kind", + field=models.CharField( + choices=[ + ("message", "Сообщение"), + ("calendar_event", "Календарное событие"), + ("document", "Документ"), + ("request", "Заявление"), + ], + max_length=32, + verbose_name="Тип связи", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index ca715176..7dcb99ed 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -176,6 +176,8 @@ class Task(models.Model): class TaskLinkedObjectKind(models.TextChoices): MESSAGE = "message", "Сообщение" CALENDAR_EVENT = "calendar_event", "Календарное событие" + DOCUMENT = "document", "Документ" + REQUEST = "request", "Заявление" class TaskActivityAction(models.TextChoices): diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index 49491cb3..0326b30d 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -33,6 +33,13 @@ from employees.models import Department, EmployeeDepartment from filer.models import File as FilerFile, Folder from rest_framework import status from rest_framework.test import APIClient +from tasks.models import ( + Task, + TaskBoard, + TaskColumn, + TaskLinkedObject, + TaskLinkedObjectKind, +) from tests.conftest import _unique_phone pytestmark = pytest.mark.django_db @@ -591,6 +598,90 @@ class TestRead: } assert expected <= set(sample.keys()) + def test_list_includes_accessible_linked_tasks( + self, auth_client, make_user, api_urls + ): + """GET list отдаёт бейджи только задач с доступных досок.""" + from django.contrib.contenttypes.models import ContentType + + author = make_user("doc-task-author@example.com") + viewer = make_user("doc-task-viewer@example.com") + hidden_user = make_user("doc-task-hidden@example.com") + grant_perms(viewer, "view_document") + doc = make_document(uploaded_by=author, title="Документ с задачей") + + visible_board = TaskBoard.objects.create( + name="Видимая доска", + created_by=author, + ) + visible_board.members.add(viewer) + visible_column = TaskColumn.objects.create( + board=visible_board, + name="В работе", + position=1000, + color="#f59e0b", + ) + visible_task = Task.objects.create( + board=visible_board, + column=visible_column, + title="Доступная задача", + created_by=author, + priority="critical", + ) + + hidden_board = TaskBoard.objects.create( + name="Закрытая доска", + created_by=hidden_user, + ) + hidden_board.members.add(hidden_user) + hidden_column = TaskColumn.objects.create( + board=hidden_board, + name="Новые", + position=1000, + color="#38bdf8", + ) + hidden_task = Task.objects.create( + board=hidden_board, + column=hidden_column, + title="Недоступная задача", + created_by=hidden_user, + ) + + document_ct = ContentType.objects.get_for_model(Document) + visible_link = TaskLinkedObject.objects.create( + task=visible_task, + kind=TaskLinkedObjectKind.DOCUMENT, + content_type=document_ct, + object_id=doc.id, + created_by=author, + ) + TaskLinkedObject.objects.create( + task=hidden_task, + kind=TaskLinkedObjectKind.DOCUMENT, + content_type=document_ct, + object_id=doc.id, + created_by=hidden_user, + ) + + response = auth_client(viewer).get(api_urls["list"]) + + assert response.status_code == 200 + result = response.json()["results"][0] + assert result["linked_tasks"] == [ + { + "link_id": visible_link.id, + "id": visible_task.id, + "title": "Доступная задача", + "board_id": visible_board.id, + "board_name": "Видимая доска", + "column_id": visible_column.id, + "column_name": "В работе", + "column_color": "#f59e0b", + "priority": "critical", + "priority_display": "Критический", + } + ] + def test_get_detail_fields_and_file_url( self, auth_client, make_user, api_urls, settings ): diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index 00ef5243..0329b27f 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -4,12 +4,21 @@ from datetime import datetime from typing import Any import pytest +from django.contrib.contenttypes.models import ContentType from django.db import models from django.utils import timezone from notifications.models import Notification from rest_framework.test import APIClient from requests_app.enums import RequestStatus, RequestType +from requests_app.models import Request as EmployeeRequest +from tasks.models import ( + Task, + TaskBoard, + TaskColumn, + TaskLinkedObject, + TaskLinkedObjectKind, +) from tests.test_config import API_REQUESTS_URL @@ -213,6 +222,94 @@ def test_list_addressed_to_me_shows_only_recipient_and_cc_requests( assert cc_request.id in ids +def test_list_includes_accessible_linked_tasks( + auth_client, regular_user: models.Model, make_user, make_request +) -> None: + """Список заявлений отдаёт task-пилюли только с доступных досок.""" + author = make_user(email="request-task-author@example.com") + hidden_user = make_user(email="request-task-hidden@example.com") + employee_request = make_request( + employee=author, + type_=RequestType.DAY_OFF, + comment="Заявление со связанной задачей", + ) + employee_request.recipients.add(regular_user) + + visible_board = TaskBoard.objects.create( + name="Видимая доска заявлений", + created_by=author, + ) + visible_board.members.add(regular_user) + visible_column = TaskColumn.objects.create( + board=visible_board, + name="В работе", + position=1000, + color="#f59e0b", + ) + visible_task = Task.objects.create( + board=visible_board, + column=visible_column, + title="Доступная задача по заявлению", + created_by=author, + priority="high", + ) + + hidden_board = TaskBoard.objects.create( + name="Скрытая доска заявлений", + created_by=hidden_user, + ) + hidden_board.members.add(hidden_user) + hidden_column = TaskColumn.objects.create( + board=hidden_board, + name="Новые", + position=1000, + color="#38bdf8", + ) + hidden_task = Task.objects.create( + board=hidden_board, + column=hidden_column, + title="Недоступная задача по заявлению", + created_by=hidden_user, + ) + + request_ct = ContentType.objects.get_for_model(EmployeeRequest) + visible_link = TaskLinkedObject.objects.create( + task=visible_task, + kind=TaskLinkedObjectKind.REQUEST, + content_type=request_ct, + object_id=employee_request.id, + created_by=author, + ) + TaskLinkedObject.objects.create( + task=hidden_task, + kind=TaskLinkedObjectKind.REQUEST, + content_type=request_ct, + object_id=employee_request.id, + created_by=hidden_user, + ) + + resp = auth_client(regular_user).get(API_BASE) + + assert resp.status_code == 200 + result = next( + item for item in _results(resp.json()) if item["id"] == employee_request.id + ) + assert result["linked_tasks"] == [ + { + "link_id": visible_link.id, + "id": visible_task.id, + "title": "Доступная задача по заявлению", + "board_id": visible_board.id, + "board_name": "Видимая доска заявлений", + "column_id": visible_column.id, + "column_name": "В работе", + "column_color": "#f59e0b", + "priority": "high", + "priority_display": "Высокий", + } + ] + + def test_list_admin_without_participation_sees_nothing( auth_client, admin_user: models.Model, make_user, make_request ) -> None: diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 29c2dde7..5c1d3820 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -10,7 +10,9 @@ from rest_framework import status from rest_framework.test import APIClient from communications.models import Chat, ChatMembership, Message +from documents.models import Document from employees.models import Department, EmployeeDepartment, RoleAssignment +from requests_app.models import Request as EmployeeRequest from schedule.models import Calendar, CalendarRelation, Event from tasks.models import Task, TaskBoard, TaskColumn from tasks.realtime import get_task_board_recipient_ids @@ -431,6 +433,161 @@ def test_task_linked_calendar_event_respects_event_access(api_client): assert reverse_linked_response.data[0]["board_name"] == "Доска с событиями" +def test_task_linked_document_respects_document_access(api_client): + owner = make_user("task-doc-owner@example.com", "+79994440026") + board_member = make_user("task-doc-member@example.com", "+79994440027") + board = TaskBoard.objects.create( + name="Доска с документами", + created_by=owner, + ) + board.members.add(board_member) + column = TaskColumn.objects.create( + board=board, + name="Новые", + position=1000, + ) + task = Task.objects.create( + board=board, + column=column, + title="Проверить регламент", + created_by=owner, + ) + document = Document.objects.create( + title="Регламент доступа", + description="Закрытый документ", + uploaded_by=owner, + sent_to_all=False, + is_regulation=True, + ) + + api_client.force_authenticate(user=board_member) + denied_response = api_client.post( + reverse("api:v1:tasks:task-linked-documents", kwargs={"pk": task.id}), + {"document_id": document.id}, + format="json", + ) + assert denied_response.status_code == status.HTTP_403_FORBIDDEN + + api_client.force_authenticate(user=owner) + link_response = api_client.post( + reverse("api:v1:tasks:task-linked-documents", kwargs={"pk": task.id}), + {"document_id": document.id}, + format="json", + ) + assert link_response.status_code == status.HTTP_201_CREATED + assert link_response.data["document"]["title"] == "Регламент доступа" + assert link_response.data["document"]["is_regulation"] is True + assert link_response.data["can_open"] is True + assert link_response.data["object_url"] == ( + f"/documents?document={document.id}" + ) + + api_client.force_authenticate(user=board_member) + linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-documents", kwargs={"pk": task.id}) + ) + assert linked_response.status_code == status.HTTP_200_OK + assert linked_response.data[0]["document_id"] == document.id + assert linked_response.data[0]["document"]["title"] == "Регламент доступа" + assert linked_response.data[0]["can_open"] is False + assert linked_response.data[0]["object_url"] is None + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-document-tasks"), + {"document_id": document.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_403_FORBIDDEN + + api_client.force_authenticate(user=owner) + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-document-tasks"), + {"document_id": document.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_200_OK + assert reverse_linked_response.data[0]["id"] == task.id + assert reverse_linked_response.data[0]["title"] == "Проверить регламент" + assert ( + reverse_linked_response.data[0]["linked_documents_count"] == 1 + ) + + +def test_task_linked_request_respects_request_access(api_client): + owner = make_user("task-request-owner@example.com", "+79994440028") + board_member = make_user("task-request-member@example.com", "+79994440029") + board = TaskBoard.objects.create( + name="Доска с заявлениями", + created_by=owner, + ) + board.members.add(board_member) + column = TaskColumn.objects.create( + board=board, + name="Новые", + position=1000, + ) + task = Task.objects.create( + board=board, + column=column, + title="Проверить заявление", + created_by=owner, + ) + employee_request = EmployeeRequest.objects.create( + employee=owner, + type="vacation", + title="Отпуск", + comment="Заявление к задаче", + ) + + api_client.force_authenticate(user=board_member) + denied_response = api_client.post( + reverse("api:v1:tasks:task-linked-requests", kwargs={"pk": task.id}), + {"request_id": employee_request.id}, + format="json", + ) + assert denied_response.status_code == status.HTTP_403_FORBIDDEN + + api_client.force_authenticate(user=owner) + link_response = api_client.post( + reverse("api:v1:tasks:task-linked-requests", kwargs={"pk": task.id}), + {"request_id": employee_request.id}, + format="json", + ) + assert link_response.status_code == status.HTTP_201_CREATED + assert link_response.data["request"]["title"] == "Отпуск" + assert link_response.data["request"]["comment"] == "Заявление к задаче" + assert link_response.data["can_open"] is True + assert link_response.data["object_url"] == ( + f"/requests?request={employee_request.id}" + ) + + api_client.force_authenticate(user=board_member) + linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-requests", kwargs={"pk": task.id}) + ) + assert linked_response.status_code == status.HTTP_200_OK + assert linked_response.data[0]["request_id"] == employee_request.id + assert linked_response.data[0]["request"]["title"] == "Отпуск" + assert linked_response.data[0]["can_open"] is False + assert linked_response.data[0]["object_url"] is None + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-request-tasks"), + {"request_id": employee_request.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_403_FORBIDDEN + + api_client.force_authenticate(user=owner) + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-request-tasks"), + {"request_id": employee_request.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_200_OK + assert reverse_linked_response.data[0]["id"] == task.id + assert reverse_linked_response.data[0]["title"] == "Проверить заявление" + assert ( + reverse_linked_response.data[0]["linked_requests_count"] == 1 + ) + + def test_task_activity_records_core_actions(api_client, user): api_client.force_authenticate(user=user) board_response = api_client.get( @@ -535,3 +692,125 @@ def test_chat_message_serialization_shows_only_accessible_linked_tasks(api_clien ) assert hidden_response.status_code == status.HTTP_200_OK assert hidden_response.data["messages"][0]["linked_tasks"] == [] + + +def test_task_comments_use_communications_and_update_count(api_client, user): + api_client.force_authenticate(user=user) + board = TaskBoard.objects.create( + name="Доска с комментариями", + created_by=user, + ) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Задача с обсуждением", + created_by=user, + ) + + detail_response = api_client.get( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) + ) + assert detail_response.status_code == status.HTTP_200_OK + assert detail_response.data["comments_count"] == 0 + + empty_response = api_client.post( + reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}), + {"text": ""}, + format="json", + ) + assert empty_response.status_code == status.HTTP_400_BAD_REQUEST + + create_response = api_client.post( + reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}), + {"text": "Нужно уточнить детали"}, + format="json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + assert create_response.data["task"] == task.id + assert create_response.data["text"] == "Нужно уточнить детали" + assert create_response.data["author"]["id"] == user.id + + task_ct = ContentType.objects.get_for_model(Task) + chat = Chat.objects.get( + type="comments", + context_content_type=task_ct, + context_object_id=task.id, + ) + message = Message.objects.get(chat=chat) + assert message.content == "Нужно уточнить детали" + assert message.author == user + + list_response = api_client.get( + reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}) + ) + assert list_response.status_code == status.HTTP_200_OK + assert [comment["text"] for comment in list_response.data] == [ + "Нужно уточнить детали" + ] + + detail_response = api_client.get( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) + ) + assert detail_response.data["comments_count"] == 1 + + delete_response = api_client.delete( + reverse( + "api:v1:tasks:task-delete-comment", + kwargs={"pk": task.id, "comment_id": message.id}, + ) + ) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + message.refresh_from_db() + assert message.is_deleted is True + + assert api_client.get( + reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}) + ).data == [] + assert api_client.get( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) + ).data["comments_count"] == 0 + + +def test_task_comments_chat_access_follows_board_access(api_client): + owner = make_user("task-comments-owner@example.com", "+79994440024") + member = make_user("task-comments-member@example.com", "+79994440025") + outsider = make_user("task-comments-outsider@example.com", "+79994440026") + board = TaskBoard.objects.create(name="Закрытая доска", created_by=owner) + board.members.add(member) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Закрытая задача", + created_by=owner, + ) + + api_client.force_authenticate(user=owner) + create_response = api_client.post( + reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}), + {"text": "Комментарий закрытой доски"}, + format="json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + + chat = Chat.objects.get( + type="comments", + context_content_type=ContentType.objects.get_for_model(Task), + context_object_id=task.id, + ) + + api_client.force_authenticate(user=member) + member_response = api_client.get( + reverse("api:v1:chats-messages", kwargs={"pk": chat.id}) + ) + assert member_response.status_code == status.HTTP_200_OK + assert member_response.data["messages"][0]["content"] == ( + "Комментарий закрытой доски" + ) + + api_client.force_authenticate(user=outsider) + outsider_response = api_client.get( + reverse("api:v1:chats-messages", kwargs={"pk": chat.id}) + ) + assert outsider_response.status_code == status.HTTP_403_FORBIDDEN diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index c57249e8..83ed1346 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -29,13 +29,16 @@ import { Trash2, ScrollText, Loader2, + Link2, } from "lucide-react"; import { DocumentAcknowledgementsReport } from "@/components/documents/DocumentAcknowledgementsReport"; import { DocumentMetadataEditor } from "@/components/documents/DocumentMetadataEditor"; import { DocumentDetailModal } from "@/components/documents/DocumentDetailModal"; +import { DocumentTaskLinks } from "@/components/documents/DocumentTaskLinks"; import { FolderTree, type FolderNode } from "@/components/documents/folders"; import { BulkActionsToolbar, useDocumentSelection } from "@/components/documents/batch"; import { TagManagementModal } from "@/components/documents/tags"; +import TaskLinkPill from "@/components/tasks/TaskLinkPill"; import { Modal } from "@/components/ui"; // Динамический импорт компонентов с PDF обработкой (избегаем SSR ошибок с DOMMatrix) @@ -252,6 +255,7 @@ function DocumentsPageContent() { const [showMetadataEditor, setShowMetadataEditor] = useState(false); const [selectedDocument, setSelectedDocument] = useState(null); const [metadataDocument, setMetadataDocument] = useState(null); + const [taskLinkDocument, setTaskLinkDocument] = useState(null); const [previewFile, setPreviewFile] = useState<{ url: string; name: string } | null>(null); const [pdfViewerFile, setPdfViewerFile] = useState<{ url: string; name: string } | null>(null); @@ -666,6 +670,11 @@ function DocumentsPageContent() { setDocumentMenuOpenId(null); }; + const openDocumentTaskLinkFromMenu = (documentItem: Document) => { + setTaskLinkDocument(documentItem); + setDocumentMenuOpenId(null); + }; + const deleteDocumentFromMenu = async (documentItem: Document) => { setDocumentMenuOpenId(null); @@ -1541,6 +1550,15 @@ function DocumentsPageContent() { )} + +
{isDocumentSelected ? ( @@ -1605,6 +1623,23 @@ function DocumentsPageContent() {

)} + {doc.linked_tasks && doc.linked_tasks.length > 0 && ( +
+ {doc.linked_tasks.slice(0, 3).map((task) => ( + + ))} + {doc.linked_tasks.length > 3 && ( + + +{doc.linked_tasks.length - 3} + + )} +
+ )} +
{doc.acknowledgement_required && !doc.is_acknowledged ? (
{taskLinkMode === "existing" ? ( - + + {filteredTaskLinkTasks.map((task: TaskCard) => ( + + ))} + + +
) : (
+ + {priority.urgencyLabel} срочность + {task.labels && task.labels.length > 0 ? ( @@ -329,9 +402,6 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) { ) : null}
- - {task.priority_display || priority.label} - {task.assignee ? ( @@ -339,7 +409,7 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) { ) : null} {task.due_date ? ( - + {formatDate(task.due_date)} @@ -350,6 +420,12 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) { {task.linked_objects_count || task.linked_messages_count} ) : null} + {(task.comments_count || 0) > 0 ? ( + + + {task.comments_count} + + ) : null}
); @@ -655,6 +731,203 @@ function LinkedCalendarEventCard({ ); } +function LinkedDocumentCard({ + link, + onUnlink, + disabled, +}: { + link: TaskLinkedDocument; + onUnlink: (linkId: number) => void; + disabled?: boolean; +}) { + const document = link.document; + const canOpen = Boolean(link.can_open && link.object_url); + const mainContent = ( + <> +
+
+ + + Документ + + {document?.is_regulation ? ( + + Регламент + + ) : null} + {canOpen ? ( + + Открыть + + ) : null} +
+

+ {document?.title || "Документ не найден"} +

+ {document?.uploaded_at || document?.created_at ? ( +

+ {formatDate(document.uploaded_at || document.created_at)} +

+ ) : null} +
+ + {document?.description ? ( +

+ {document.description} +

+ ) : null} + +
+ {document?.folder_path ? ( + + {document.folder_path} + + ) : null} + {document?.file_name ? ( + + + {document.file_name} + + ) : null} + + Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"} + +
+ + ); + + return ( +
+
+ {canOpen && link.object_url ? ( + + {mainContent} + + ) : ( +
+ {mainContent} +
+ )} + + +
+
+ ); +} + +function LinkedRequestCard({ + link, + onUnlink, + disabled, +}: { + link: TaskLinkedRequest; + onUnlink: (linkId: number) => void; + disabled?: boolean; +}) { + const request = link.request; + const canOpen = Boolean(link.can_open && link.object_url); + const periodLabel = request?.date_from + ? `${formatDate(request.date_from)}${request.date_to ? ` - ${formatDate(request.date_to)}` : ""}` + : ""; + const mainContent = ( + <> +
+
+ + + Заявление + + {request?.status_display ? ( + + {request.status_display} + + ) : null} + {canOpen ? ( + + Открыть + + ) : null} +
+

+ {request?.display_title || request?.title || "Заявление не найдено"} +

+ {periodLabel ? ( +

+ {periodLabel} +

+ ) : null} +
+ + {request?.comment ? ( +

+ {request.comment} +

+ ) : null} + +
+ {request?.type_display ? ( + + {request.type_display} + + ) : null} + {request?.employee ? ( + + + {displayUserName(request.employee)} + + ) : null} + + Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"} + +
+ + ); + + return ( +
+
+ {canOpen && link.object_url ? ( + + {mainContent} + + ) : ( +
+ {mainContent} +
+ )} + + +
+
+ ); +} + const activityFieldLabels: Record = { title: "название", description: "описание", @@ -755,7 +1028,13 @@ export default function TasksPage() { const [linkedObjectsOpen, setLinkedObjectsOpen] = useState(false); const [linkedMessages, setLinkedMessages] = useState([]); const [linkedEvents, setLinkedEvents] = useState([]); + const [linkedDocuments, setLinkedDocuments] = useState([]); + const [linkedRequests, setLinkedRequests] = useState([]); const [linkedObjectsLoading, setLinkedObjectsLoading] = useState(false); + const [commentsOpen, setCommentsOpen] = useState(false); + const [taskComments, setTaskComments] = useState([]); + const [taskCommentsLoading, setTaskCommentsLoading] = useState(false); + const [commentDraft, setCommentDraft] = useState(""); const [activityOpen, setActivityOpen] = useState(false); const [taskActivities, setTaskActivities] = useState([]); const [taskActivityLoading, setTaskActivityLoading] = useState(false); @@ -795,12 +1074,16 @@ export default function TasksPage() { const loadTaskLinkedObjects = useCallback(async (taskId: number) => { setLinkedObjectsLoading(true); try { - const [messages, events] = await Promise.all([ + const [messages, events, documents, requests] = await Promise.all([ apiClient.getTaskLinkedMessages(taskId), apiClient.getTaskLinkedEvents(taskId), + apiClient.getTaskLinkedDocuments(taskId), + apiClient.getTaskLinkedRequests(taskId), ]); setLinkedMessages(messages); setLinkedEvents(events); + setLinkedDocuments(documents); + setLinkedRequests(requests); } catch (linksError) { setError(getTaskError(linksError, "Не удалось загрузить связанные объекты")); } finally { @@ -820,6 +1103,18 @@ export default function TasksPage() { } }, []); + const loadTaskComments = useCallback(async (taskId: number) => { + setTaskCommentsLoading(true); + try { + const data = await apiClient.getTaskComments(taskId); + setTaskComments(data); + } catch (commentsError) { + setError(getTaskError(commentsError, "Не удалось загрузить комментарии")); + } finally { + setTaskCommentsLoading(false); + } + }, []); + useEffect(() => { selectedBoardIdRef.current = selectedBoardId; }, [selectedBoardId]); @@ -1121,8 +1416,9 @@ export default function TasksPage() { () => activeColumns.find((column) => column.id === viewTask?.column) || null, [activeColumns, viewTask?.column], ); - const linkedObjectsCount = linkedMessages.length + linkedEvents.length; + const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length; const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount; + const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length; useEffect(() => { if ( @@ -1137,8 +1433,13 @@ export default function TasksPage() { if (!viewTaskId) { setLinkedMessages([]); setLinkedEvents([]); + setLinkedDocuments([]); + setLinkedRequests([]); setTaskActivities([]); + setTaskComments([]); + setCommentDraft(""); setLinkedObjectsOpen(false); + setCommentsOpen(false); setActivityOpen(false); return; } @@ -1148,6 +1449,11 @@ export default function TasksPage() { } }, [linkedObjectsOpen, loadTaskLinkedObjects, viewTask?.linked_objects_count, viewTaskId]); + useEffect(() => { + if (!viewTaskId || !commentsOpen) return; + void loadTaskComments(viewTaskId); + }, [commentsOpen, loadTaskComments, viewTask?.comments_count, viewTaskId]); + useEffect(() => { if (!viewTaskId || !activityOpen) return; void loadTaskActivity(viewTaskId); @@ -1169,10 +1475,15 @@ export default function TasksPage() { setTaskMenuOpen(false); setViewColumnMenuOpen(false); setLinkedObjectsOpen(false); + setCommentsOpen(false); setActivityOpen(false); setLinkedMessages([]); setLinkedEvents([]); + setLinkedDocuments([]); + setLinkedRequests([]); + setTaskComments([]); setTaskActivities([]); + setCommentDraft(""); setCardMenuTaskId(null); }, []); @@ -1187,9 +1498,14 @@ export default function TasksPage() { setViewColumnMenuOpen(false); setLinkedMessages([]); setLinkedEvents([]); + setLinkedDocuments([]); + setLinkedRequests([]); + setTaskComments([]); setTaskActivities([]); setLinkedObjectsOpen(false); + setCommentsOpen(false); setActivityOpen(false); + setCommentDraft(""); }, [saving]); const openEditTask = useCallback((task: TaskCard) => { @@ -1481,6 +1797,39 @@ export default function TasksPage() { await deleteTask(viewTask); }, [deleteTask, viewTask]); + const addViewedTaskComment = useCallback(async () => { + if (!board || !viewTask || saving || !commentDraft.trim()) return; + + setSaving(true); + setError(null); + try { + const created = await apiClient.addTaskComment(viewTask.id, commentDraft.trim()); + setTaskComments((current) => [...current, created]); + setCommentDraft(""); + await loadBoard(board.id); + } catch (commentError) { + setError(getTaskError(commentError, "Не удалось добавить комментарий")); + } finally { + setSaving(false); + } + }, [board, commentDraft, loadBoard, saving, viewTask]); + + const deleteViewedTaskComment = useCallback(async (commentId: number) => { + if (!board || !viewTask || saving) return; + + setSaving(true); + setError(null); + try { + await apiClient.deleteTaskComment(viewTask.id, commentId); + setTaskComments((current) => current.filter((comment) => comment.id !== commentId)); + await loadBoard(board.id); + } catch (commentError) { + setError(getTaskError(commentError, "Не удалось удалить комментарий")); + } finally { + setSaving(false); + } + }, [board, loadBoard, saving, viewTask]); + const unlinkMessageFromViewedTask = useCallback(async (linkId: number) => { if (!board || !viewTask || saving) return; @@ -1515,6 +1864,40 @@ export default function TasksPage() { } }, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]); + const unlinkDocumentFromViewedTask = useCallback(async (linkId: number) => { + if (!board || !viewTask || saving) return; + + setSaving(true); + setError(null); + try { + await apiClient.unlinkTaskDocument(viewTask.id, linkId); + await loadTaskLinkedObjects(viewTask.id); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (unlinkError) { + setError(getTaskError(unlinkError, "Не удалось убрать связь с документом")); + } finally { + setSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]); + + const unlinkRequestFromViewedTask = useCallback(async (linkId: number) => { + if (!board || !viewTask || saving) return; + + setSaving(true); + setError(null); + try { + await apiClient.unlinkTaskRequest(viewTask.id, linkId); + await loadTaskLinkedObjects(viewTask.id); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (unlinkError) { + setError(getTaskError(unlinkError, "Не удалось убрать связь с заявлением")); + } finally { + setSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]); + const changeViewedTaskColumn = useCallback(async (columnId: number) => { if (!board || !viewTask || saving || viewTask.column === columnId) { setViewColumnMenuOpen(false); @@ -2108,7 +2491,7 @@ export default function TasksPage() { ) : null} {viewTask.due_date ? ( - + {formatDate(viewTask.due_date)} @@ -2183,6 +2566,89 @@ export default function TasksPage() { ) : null} +
+ + + {commentsOpen ? ( +
+ {taskCommentsLoading ? ( +
+ +
+ ) : taskComments.length > 0 ? ( +
+ {taskComments.map((comment) => { + const canDeleteComment = Boolean( + user?.auth?.is_staff || + user?.auth?.is_superuser || + comment.author?.id === user?.id, + ); + return ( +
+
+
+

+ {comment.author ? displayUserName(comment.author) : "Сотрудник"} +

+

+ {formatDateTime(comment.created_at)} +

+
+ {canDeleteComment ? ( + deleteViewedTaskComment(comment.id)} + /> + ) : null} +
+

+ {comment.text} +

+
+ ); + })} +
+ ) : ( +
+

Комментариев пока нет

+
+ )} + + +
+ ) : null} +
+
) : (