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 ? (
+ {taskLinkDocument && (
+ setTaskLinkDocument(null)}
+ onLinked={() => {
+ loadDocuments();
+ }}
+ />
+ )}
+
{/* File Preview Modal */}
{previewFile && (
([]);
const [taskLinkBoardId, setTaskLinkBoardId] = useState("");
const [taskLinkTaskId, setTaskLinkTaskId] = useState("");
+ const [taskLinkSearch, setTaskLinkSearch] = useState("");
const [taskLinkMode, setTaskLinkMode] = useState("existing");
const [taskLinkNewTitle, setTaskLinkNewTitle] = useState("");
const [taskLinkNewDescription, setTaskLinkNewDescription] = useState("");
@@ -816,6 +835,14 @@ function MessageDialogPageContent() {
() => (selectedTaskLinkBoard?.tasks || []).find((task) => task.id === taskLinkTaskId) || null,
[selectedTaskLinkBoard?.tasks, taskLinkTaskId],
);
+ const availableTaskLinkTasks = useMemo(
+ () => selectedTaskLinkBoard?.tasks || [],
+ [selectedTaskLinkBoard?.tasks],
+ );
+ const filteredTaskLinkTasks = useMemo(
+ () => availableTaskLinkTasks.filter((task) => taskMatchesSearch(task, taskLinkSearch)),
+ [availableTaskLinkTasks, taskLinkSearch],
+ );
const selectedTaskLinkColumn = useMemo(
() => (
(selectedTaskLinkBoard?.columns || []).find((column) => column.id === taskLinkColumnId) ||
@@ -845,6 +872,7 @@ function MessageDialogPageContent() {
const firstColumn = firstBoardWithTasks?.columns?.find((column) => !column.is_archived) || null;
setTaskLinkBoardId(firstBoardWithTasks?.id || "");
setTaskLinkTaskId(firstBoardWithTasks?.tasks?.[0]?.id || "");
+ setTaskLinkSearch("");
setTaskLinkColumnId(firstColumn?.id || "");
setTaskLinkMode(firstBoardWithTasks?.tasks?.length ? "existing" : "create");
setTaskLinkDetailsOpen(false);
@@ -864,6 +892,7 @@ function MessageDialogPageContent() {
setTaskLinkBoardId(boardId);
const nextBoard = taskLinkBoards.find((item) => item.id === boardId) || null;
setTaskLinkTaskId(nextBoard?.tasks?.[0]?.id || "");
+ setTaskLinkSearch("");
setTaskLinkColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || "");
setTaskLinkLabelIds([]);
if (!nextBoard?.tasks?.length) {
@@ -871,12 +900,19 @@ function MessageDialogPageContent() {
}
}, [taskLinkBoards]);
+ const handleTaskLinkSearchChange = useCallback((value: string) => {
+ setTaskLinkSearch(value);
+ const nextTask = availableTaskLinkTasks.find((task) => taskMatchesSearch(task, value));
+ setTaskLinkTaskId(nextTask?.id || "");
+ }, [availableTaskLinkTasks]);
+
const handleCloseTaskLinkModal = useCallback(() => {
if (taskLinkLoading) return;
setTaskLinkMessage(null);
setTaskLinkError(null);
setTaskLinkBoardId("");
setTaskLinkTaskId("");
+ setTaskLinkSearch("");
setTaskLinkMode("existing");
setTaskLinkNewTitle("");
setTaskLinkNewDescription("");
@@ -953,6 +989,7 @@ function MessageDialogPageContent() {
setTaskLinkError(null);
setTaskLinkBoardId("");
setTaskLinkTaskId("");
+ setTaskLinkSearch("");
setTaskLinkMode("existing");
setTaskLinkNewTitle("");
setTaskLinkNewDescription("");
@@ -1648,22 +1685,36 @@ function MessageDialogPageContent() {
{taskLinkMode === "existing" ? (
-