diff --git a/backend/api/v1/employees/serializers/employee.py b/backend/api/v1/employees/serializers/employee.py index d3feac7d..1d200a59 100644 --- a/backend/api/v1/employees/serializers/employee.py +++ b/backend/api/v1/employees/serializers/employee.py @@ -6,6 +6,7 @@ from typing import Any, Dict from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from django.utils import timezone from employees.models import EmployeeDepartment, Position, Skill from employees.services.personnel_state import resolve_employee_personnel_state @@ -23,6 +24,53 @@ from .shared import ( Employee = get_user_model() +def _linked_task_payloads_for_employee(obj, user) -> list[dict]: + if not user or not getattr(user, "is_authenticated", False): + return [] + + try: + 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(Employee) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=TaskLinkedObjectKind.EMPLOYEE, + 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 + ] + + def _employee_personnel_state_payload(obj): prefetched = getattr(obj, "_prefetched_objects_cache", {}) actions = prefetched.get("actions") if prefetched else None @@ -96,6 +144,7 @@ class EmployeeSerializer(serializers.ModelSerializer): departments = serializers.SerializerMethodField() auth = serializers.SerializerMethodField(read_only=True) personnel_state = serializers.SerializerMethodField(read_only=True) + linked_tasks = serializers.SerializerMethodField() class Meta: model = Employee @@ -130,6 +179,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "date_joined", "auth", "personnel_state", + "linked_tasks", ) read_only_fields = ( "is_ldap_managed", @@ -142,6 +192,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "date_joined", "auth", "personnel_state", + "linked_tasks", ) extra_kwargs = { "password": {"write_only": True, "required": False}, @@ -295,6 +346,16 @@ class EmployeeSerializer(serializers.ModelSerializer): def get_personnel_state(self, obj): return _employee_personnel_state_payload(obj) + @extend_schema_field(serializers.ListField(child=serializers.DictField())) + def get_linked_tasks(self, obj): + prefetched = getattr(obj, "_linked_task_payloads", None) + if prefetched is not None: + return prefetched + + request = self.context.get("request") + user = getattr(request, "user", None) + return _linked_task_payloads_for_employee(obj, user) + class EmployeeBriefSerializer(serializers.ModelSerializer): display_name = serializers.SerializerMethodField() @@ -354,6 +415,7 @@ class EmployeeListSerializer(serializers.ModelSerializer): skills = SkillSerializer(many=True, read_only=True) department_relation = serializers.SerializerMethodField() personnel_state = serializers.SerializerMethodField(read_only=True) + linked_tasks = serializers.SerializerMethodField() class Meta: model = Employee @@ -373,12 +435,23 @@ class EmployeeListSerializer(serializers.ModelSerializer): "display_name", "department_relation", "personnel_state", + "linked_tasks", ) @extend_schema_field(EmployeePersonnelStateSerializer()) def get_personnel_state(self, obj): return _employee_personnel_state_payload(obj) + @extend_schema_field(serializers.ListField(child=serializers.DictField())) + def get_linked_tasks(self, obj): + prefetched = getattr(obj, "_linked_task_payloads", None) + if prefetched is not None: + return prefetched + + request = self.context.get("request") + user = getattr(request, "user", None) + return _linked_task_payloads_for_employee(obj, user) + @extend_schema_field(EmployeeDepartmentRelationSerializer(allow_null=True)) def get_department_relation(self, obj): is_member = getattr(obj, "_is_dept_member", None) diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index cacccb88..abfaf9d3 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging from common.emails import send_templated_mail +from django.contrib.contenttypes.models import ContentType from django.db.models import Exists, Max, OuterRef, Prefetch, Q, Subquery from django.utils.crypto import get_random_string from employees.constants import ( @@ -211,6 +212,72 @@ class EmployeeViewSet(viewsets.ModelViewSet): return qs + def _attach_linked_task_payloads(self, employees: list[Employee]) -> None: + """Предзагрузить компактные бейджи задач для списка сотрудников.""" + if not employees: + return + + user = getattr(self.request, "user", None) + employee_ids = [employee.id for employee in employees] + mapping = {employee_id: [] for employee_id in employee_ids} + + if user and user.is_authenticated: + from tasks.access import task_board_access_q + from tasks.models import ( + TaskBoard, + TaskLinkedObject, + TaskLinkedObjectKind, + ) + + employee_ct = ContentType.objects.get_for_model(Employee) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=TaskLinkedObjectKind.EMPLOYEE, + content_type=employee_ct, + object_id__in=employee_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 employee in employees: + employee._linked_task_payloads = mapping.get(employee.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) + + employees = list(queryset) + self._attach_linked_task_payloads(employees) + serializer = self.get_serializer(employees, many=True) + return Response(serializer.data) + def get_serializer_class(self): if self.action == "list": return EmployeeListSerializer diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 047282ca..1a8585fc 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -1,6 +1,7 @@ from django.db.models import Max from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.utils import timezone from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -12,7 +13,8 @@ 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 employees.models import Department, EmployeeDepartment +from employees.services.personnel_state import resolve_employee_personnel_state from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest from schedule.models import Event @@ -20,6 +22,7 @@ from api.v1.schedule.serializers import EventSerializer from tasks.access import ( user_can_access_calendar_event, user_can_access_document, + user_can_access_employee, user_can_access_employee_request, user_can_access_procurement_request, user_can_access_task_board, @@ -145,6 +148,7 @@ class TaskSerializer(serializers.ModelSerializer): linked_documents_count = serializers.SerializerMethodField() linked_requests_count = serializers.SerializerMethodField() linked_procurement_requests_count = serializers.SerializerMethodField() + linked_employees_count = serializers.SerializerMethodField() linked_objects_count = serializers.SerializerMethodField() comments_count = serializers.SerializerMethodField() @@ -174,6 +178,7 @@ class TaskSerializer(serializers.ModelSerializer): "linked_documents_count", "linked_requests_count", "linked_procurement_requests_count", + "linked_employees_count", "linked_objects_count", "comments_count", "created_at", @@ -192,6 +197,7 @@ class TaskSerializer(serializers.ModelSerializer): "linked_documents_count", "linked_requests_count", "linked_procurement_requests_count", + "linked_employees_count", "linked_objects_count", "comments_count", "created_at", @@ -238,6 +244,14 @@ class TaskSerializer(serializers.ModelSerializer): kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, ).count() + @extend_schema_field(serializers.IntegerField()) + def get_linked_employees_count(self, obj): + if hasattr(obj, "linked_employees_count"): + return obj.linked_employees_count + return obj.linked_objects.filter( + kind=TaskLinkedObjectKind.EMPLOYEE, + ).count() + @extend_schema_field(serializers.IntegerField()) def get_linked_objects_count(self, obj): if hasattr(obj, "linked_objects_count"): @@ -708,3 +722,102 @@ class TaskLinkedProcurementRequestSerializer(serializers.ModelSerializer): def get_procurement_request_content_type(): return ContentType.objects.get_for_model(ProcurementRequest) + + +class TaskLinkedEmployeeSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + employee = serializers.SerializerMethodField() + employee_id = serializers.IntegerField(source="object_id", read_only=True) + can_open = serializers.SerializerMethodField() + object_url = serializers.SerializerMethodField() + + class Meta: + model = TaskLinkedObject + fields = [ + "id", + "kind", + "employee_id", + "employee", + "can_open", + "object_url", + "created_by", + "created_at", + ] + read_only_fields = fields + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_employee(self, obj): + if obj.kind != TaskLinkedObjectKind.EMPLOYEE: + return None + + employee = getattr(obj, "content_object", None) + if employee is None: + return None + + departments = [] + links = getattr(employee, "dept_links", None) + if links is None: + links = EmployeeDepartment.objects.select_related( + "department", + "role", + ).filter(employee=employee, is_active=True) + departments = [ + { + "id": link.department_id, + "name": link.department.name if link.department_id else None, + "role_id": link.role_id, + "role_name": link.role.name if link.role_id else None, + "is_head": ( + link.department.head_id == employee.id + if link.department_id + else False + ), + } + for link in links + ] + personnel_state = resolve_employee_personnel_state( + employee, + timezone.localdate(), + ) + + return { + **EmployeeBriefSerializer(employee).data, + "position": ( + { + "id": employee.position_id, + "name": employee.position.name, + } + if employee.position_id + else None + ), + "departments": departments, + "is_active": employee.is_active, + "personnel_state": { + "status": personnel_state.status, + "label": personnel_state.label or "Работает", + "action_id": personnel_state.action_id, + "date_from": personnel_state.date_from, + "date_to": personnel_state.date_to, + "expects_attendance": personnel_state.expects_attendance, + }, + } + + @extend_schema_field(serializers.BooleanField()) + def get_can_open(self, obj): + employee = getattr(obj, "content_object", None) + request = self.context.get("request") + user = getattr(request, "user", None) + if obj.kind != TaskLinkedObjectKind.EMPLOYEE or employee is None or not user: + return False + return user_can_access_employee(user, employee) + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_object_url(self, obj): + employee = getattr(obj, "content_object", None) + if employee is None or not self.get_can_open(obj): + return None + return f"/users/{employee.id}" + + +def get_employee_content_type(): + return ContentType.objects.get_for_model(User) diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 083c4a54..0beee5e1 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.contrib.auth import get_user_model from django.db.models import ( Count, IntegerField, @@ -25,6 +26,7 @@ from tasks.access import ( task_board_access_q, user_can_access_calendar_event, user_can_access_document, + user_can_access_employee, user_can_access_employee_request, user_can_access_procurement_request, ) @@ -51,6 +53,7 @@ from .serializers import ( TaskLabelSerializer, TaskLinkedCalendarEventSerializer, TaskLinkedDocumentSerializer, + TaskLinkedEmployeeSerializer, TaskLinkedMessageSerializer, TaskLinkedProcurementRequestSerializer, TaskLinkedRequestSerializer, @@ -58,11 +61,14 @@ from .serializers import ( create_default_columns, get_calendar_event_content_type, get_document_content_type, + get_employee_content_type, get_message_content_type, get_procurement_request_content_type, get_request_content_type, ) +Employee = get_user_model() + def create_task_activity( task, @@ -299,6 +305,13 @@ class TaskViewSet(viewsets.ModelViewSet): ), distinct=True, ), + linked_employees_count=Count( + "linked_objects", + filter=Q( + linked_objects__kind=TaskLinkedObjectKind.EMPLOYEE, + ), + distinct=True, + ), linked_objects_count=Count("linked_objects", distinct=True), comments_count=Coalesce( Subquery(comments_subquery, output_field=IntegerField()), @@ -715,6 +728,43 @@ class TaskViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + @action(detail=False, methods=["get"], url_path="linked-employee-tasks") + def linked_employee_tasks(self, request): + employee_id = request.query_params.get("employee_id") + try: + employee_id = int(employee_id) + except (TypeError, ValueError): + return Response( + {"employee_id": ["Укажите ID сотрудника."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + employee = Employee.objects.select_related("position").get(id=employee_id) + except Employee.DoesNotExist: + return Response( + {"employee_id": ["Сотрудник не найден."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_employee(request.user, employee): + return Response( + {"detail": "Нет доступа к этому сотруднику."}, + status=status.HTTP_403_FORBIDDEN, + ) + + queryset = ( + self.get_queryset() + .filter( + linked_objects__kind=TaskLinkedObjectKind.EMPLOYEE, + linked_objects__content_type=get_employee_content_type(), + linked_objects__object_id=employee.id, + ) + .distinct() + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + def _message_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( @@ -765,6 +815,16 @@ class TaskViewSet(viewsets.ModelViewSet): .order_by("-created_at", "-id") ) + def _employee_links_queryset(self, task): + return ( + TaskLinkedObject.objects.filter( + task=task, + kind=TaskLinkedObjectKind.EMPLOYEE, + ) + .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() @@ -1219,6 +1279,109 @@ class TaskViewSet(viewsets.ModelViewSet): ) return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["get", "post"], url_path="linked-employees") + def linked_employees(self, request, pk=None): + task = self.get_object() + if request.method == "GET": + serializer = TaskLinkedEmployeeSerializer( + self._employee_links_queryset(task), + many=True, + context={"request": request}, + ) + return Response(serializer.data) + + employee_id = request.data.get("employee_id") + try: + employee_id = int(employee_id) + except (TypeError, ValueError): + return Response( + {"employee_id": ["Укажите ID сотрудника."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + employee = Employee.objects.select_related("position").get(id=employee_id) + except Employee.DoesNotExist: + return Response( + {"employee_id": ["Сотрудник не найден."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_employee(request.user, employee): + return Response( + {"detail": "Нет доступа к этому сотруднику."}, + status=status.HTTP_403_FORBIDDEN, + ) + + link, created = TaskLinkedObject.objects.get_or_create( + task=task, + kind=TaskLinkedObjectKind.EMPLOYEE, + content_type=get_employee_content_type(), + object_id=employee.id, + defaults={"created_by": request.user}, + ) + serializer = TaskLinkedEmployeeSerializer( + link, + context={"request": request}, + ) + if created: + create_task_activity( + task, + request.user, + TaskActivityAction.LINKED, + object_kind=TaskLinkedObjectKind.EMPLOYEE, + object_id=employee.id, + metadata={ + "object_label": employee.get_full_name() or employee.email, + "object_type": "Сотрудник", + }, + ) + send_task_board_update( + task.board, + "linked", + "employee", + employee.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-employees/(?P\d+)", + ) + def unlink_employee(self, request, pk=None, link_id=None): + task = self.get_object() + try: + link = self._employee_links_queryset(task).get(id=link_id) + except TaskLinkedObject.DoesNotExist: + return Response( + {"detail": "Связь не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ) + + employee_id = link.object_id + link.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.UNLINKED, + object_kind=TaskLinkedObjectKind.EMPLOYEE, + object_id=employee_id, + metadata={"object_type": "Сотрудник"}, + ) + send_task_board_update( + task.board, + "unlinked", + "employee", + employee_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/tasks/access.py b/backend/tasks/access.py index 8ce18f4f..d1cd0375 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -144,3 +144,7 @@ def user_can_access_procurement_request(user, procurement_request) -> bool: procurement_request.status == ProcurementStatus.APPROVED and procurement_request.processing_department_id is None ) + + +def user_can_access_employee(user, employee) -> bool: + return bool(user and user.is_authenticated and employee is not None) diff --git a/backend/tasks/migrations/0008_add_employee_link_kind.py b/backend/tasks/migrations/0008_add_employee_link_kind.py new file mode 100644 index 00000000..b6f5abf2 --- /dev/null +++ b/backend/tasks/migrations/0008_add_employee_link_kind.py @@ -0,0 +1,43 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0007_add_procurement_request_link_kind"), + ] + + operations = [ + migrations.AlterField( + model_name="taskactivity", + name="object_kind", + field=models.CharField( + blank=True, + choices=[ + ("message", "Сообщение"), + ("calendar_event", "Календарное событие"), + ("document", "Документ"), + ("request", "Заявление"), + ("procurement_request", "Заявка на закупку"), + ("employee", "Сотрудник"), + ], + max_length=32, + verbose_name="Тип объекта", + ), + ), + migrations.AlterField( + model_name="tasklinkedobject", + name="kind", + field=models.CharField( + choices=[ + ("message", "Сообщение"), + ("calendar_event", "Календарное событие"), + ("document", "Документ"), + ("request", "Заявление"), + ("procurement_request", "Заявка на закупку"), + ("employee", "Сотрудник"), + ], + max_length=32, + verbose_name="Тип связи", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 75b3b60f..eaf4982a 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -179,6 +179,7 @@ class TaskLinkedObjectKind(models.TextChoices): DOCUMENT = "document", "Документ" REQUEST = "request", "Заявление" PROCUREMENT_REQUEST = "procurement_request", "Заявка на закупку" + EMPLOYEE = "employee", "Сотрудник" class TaskActivityAction(models.TextChoices): diff --git a/backend/tests/api/v1/employees/test_employees.py b/backend/tests/api/v1/employees/test_employees.py index eac962c8..20b82cf2 100644 --- a/backend/tests/api/v1/employees/test_employees.py +++ b/backend/tests/api/v1/employees/test_employees.py @@ -4,6 +4,7 @@ import datetime as dt import pytest from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient @@ -17,6 +18,13 @@ from employees.models import ( Skill, ) from employees.constants import ACTION_DISMISSED # для фильтра actually_active +from tasks.models import ( + Task, + TaskBoard, + TaskColumn, + TaskLinkedObject, + TaskLinkedObjectKind, +) from tests.conftest import _unique_phone pytestmark = pytest.mark.django_db @@ -85,6 +93,84 @@ def test_list_ok_for_authenticated(api_client: APIClient): ids = {it["id"] for it in items} assert e1.id in ids and e2.id in ids + +def test_list_includes_visible_linked_tasks_only(api_client: APIClient): + user = make_user("employee-link-user@example.com") + target = make_user("employee-link-target@example.com") + hidden_member = make_user("employee-link-hidden@example.com") + visible_board = TaskBoard.objects.create( + name="Видимая доска сотрудника", + created_by=user, + ) + visible_column = TaskColumn.objects.create( + board=visible_board, + name="Новые", + position=1000, + color="#38bdf8", + ) + visible_task = Task.objects.create( + board=visible_board, + column=visible_column, + title="Видимая задача сотрудника", + created_by=user, + priority="high", + ) + hidden_board = TaskBoard.objects.create( + name="Скрытая доска сотрудника", + created_by=hidden_member, + ) + hidden_board.members.add(hidden_member) + hidden_column = TaskColumn.objects.create( + board=hidden_board, + name="Новые", + position=1000, + color="#ef4444", + ) + hidden_task = Task.objects.create( + board=hidden_board, + column=hidden_column, + title="Скрытая задача сотрудника", + created_by=hidden_member, + priority="critical", + ) + employee_ct = ContentType.objects.get_for_model(User) + visible_link = TaskLinkedObject.objects.create( + task=visible_task, + kind=TaskLinkedObjectKind.EMPLOYEE, + content_type=employee_ct, + object_id=target.id, + created_by=user, + ) + TaskLinkedObject.objects.create( + task=hidden_task, + kind=TaskLinkedObjectKind.EMPLOYEE, + content_type=employee_ct, + object_id=target.id, + created_by=hidden_member, + ) + + api_client.force_authenticate(user=user) + response = api_client.get(reverse("api:v1:employees-list")) + + assert response.status_code == status.HTTP_200_OK + items = extract_results(response.json()) + target_payload = next(item for item in items if item["id"] == target.id) + assert target_payload["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": "#38bdf8", + "priority": "high", + "priority_display": "Высокий", + } + ] + + def test_retrieve_requires_auth(api_client: APIClient): e = make_user("x@example.com") url = reverse("api:v1:employees-detail", args=[e.pk]) diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 2c5fae10..f1740419 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -681,6 +681,74 @@ def test_task_linked_procurement_request_respects_procurement_access(api_client) assert reverse_linked_response.data[0]["linked_procurement_requests_count"] == 1 +def test_task_linked_employee_respects_task_board_access(api_client): + owner = make_user("task-employee-owner@example.com", "+79994440032") + board_member = make_user("task-employee-member@example.com", "+79994440033") + outsider = make_user("task-employee-outsider@example.com", "+79994440034") + linked_employee = make_user( + "task-employee-linked@example.com", + "+79994440035", + first_name="Гарик", + last_name="Мусик", + ) + board = TaskBoard.objects.create( + name="Доска с сотрудниками", + created_by=owner, + ) + board.members.add(board_member) + column = TaskColumn.objects.create( + board=board, + name="Новые", + position=1000, + color="#38bdf8", + ) + task = Task.objects.create( + board=board, + column=column, + title="Подготовить сотрудника", + created_by=owner, + ) + + api_client.force_authenticate(user=board_member) + link_response = api_client.post( + reverse("api:v1:tasks:task-linked-employees", kwargs={"pk": task.id}), + {"employee_id": linked_employee.id}, + format="json", + ) + assert link_response.status_code == status.HTTP_201_CREATED + assert link_response.data["employee_id"] == linked_employee.id + assert link_response.data["employee"]["full_name"] == "Мусик Гарик" + assert link_response.data["can_open"] is True + assert link_response.data["object_url"] == f"/users/{linked_employee.id}" + + linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-employees", kwargs={"pk": task.id}) + ) + assert linked_response.status_code == status.HTTP_200_OK + assert linked_response.data[0]["employee"]["email"] == linked_employee.email + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-employee-tasks"), + {"employee_id": linked_employee.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]["linked_employees_count"] == 1 + + api_client.force_authenticate(user=outsider) + task_detail_response = api_client.get( + reverse("api:v1:tasks:task-linked-employees", kwargs={"pk": task.id}) + ) + assert task_detail_response.status_code == status.HTTP_404_NOT_FOUND + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-employee-tasks"), + {"employee_id": linked_employee.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_200_OK + assert reverse_linked_response.data == [] + + def test_task_activity_records_core_actions(api_client, user): api_client.force_authenticate(user=user) board_response = api_client.get( diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index 2d724a0c..3ec5969a 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -246,7 +246,8 @@ export default function EmployeesPage() { - {!isCurrentUser ? ( +
+ {!isCurrentUser ? ( - ) : null} + ) : null} +
); })} diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 76b46909..63381676 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -57,6 +57,7 @@ import type { TaskColumn, TaskComment, TaskLinkedDocument, + TaskLinkedEmployee, TaskLinkedCalendarEvent, TaskLinkedMessage, TaskLinkedProcurementRequest, @@ -268,9 +269,14 @@ function TaskCardView({ onClick={() => onOpen(task)} className="min-w-0 flex-1 text-left" > -

- {task.title} -

+
+ + #{task.id} + +

+ {task.title} +

+
{task.description ? (

{task.description} @@ -375,9 +381,14 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {

-

- {task.title} -

+
+ + #{task.id} + +

+ {task.title} +

+
{task.description ? (

{task.description} @@ -1048,6 +1059,111 @@ function LinkedProcurementRequestCard({ ); } +function LinkedEmployeeCard({ + link, + onUnlink, + disabled, +}: { + link: TaskLinkedEmployee; + onUnlink: (linkId: number) => void; + disabled?: boolean; +}) { + const employee = link.employee; + const canOpen = Boolean(link.can_open && link.object_url); + const fullName = employee + ? ( + employee.display_name || + employee.full_name || + `${employee.last_name || ""} ${employee.first_name || ""} ${employee.patronymic || ""}`.trim() || + employee.email || + `Сотрудник #${employee.id}` + ) + : "Сотрудник не найден"; + const departmentNames = (employee?.departments || []) + .map((department) => department.name) + .filter(Boolean); + const mainContent = ( + <> +

+
+ + + Сотрудник + + {employee?.personnel_state ? ( + + {employee.personnel_state.label || employee.personnel_state.status} + + ) : null} + {canOpen ? ( + + Открыть + + ) : null} +
+

+ {fullName} +

+ {employee?.position?.name ? ( +

+ {employee.position.name} +

+ ) : null} +
+ +
+ {departmentNames.slice(0, 2).map((department) => ( + + {department} + + ))} + {employee?.email ? ( + + {employee.email} + + ) : null} + + Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"} + +
+ + ); + + return ( +
+
+ {canOpen && link.object_url ? ( + + {mainContent} + + ) : ( +
+ {mainContent} +
+ )} + + +
+
+ ); +} + const activityFieldLabels: Record = { title: "название", description: "описание", @@ -1151,6 +1267,7 @@ export default function TasksPage() { const [linkedDocuments, setLinkedDocuments] = useState([]); const [linkedRequests, setLinkedRequests] = useState([]); const [linkedProcurementRequests, setLinkedProcurementRequests] = useState([]); + const [linkedEmployees, setLinkedEmployees] = useState([]); const [linkedObjectsLoading, setLinkedObjectsLoading] = useState(false); const [commentsOpen, setCommentsOpen] = useState(false); const [taskComments, setTaskComments] = useState([]); @@ -1195,18 +1312,20 @@ export default function TasksPage() { const loadTaskLinkedObjects = useCallback(async (taskId: number) => { setLinkedObjectsLoading(true); try { - const [messages, events, documents, requests, procurementRequests] = await Promise.all([ + const [messages, events, documents, requests, procurementRequests, employees] = await Promise.all([ apiClient.getTaskLinkedMessages(taskId), apiClient.getTaskLinkedEvents(taskId), apiClient.getTaskLinkedDocuments(taskId), apiClient.getTaskLinkedRequests(taskId), apiClient.getTaskLinkedProcurementRequests(taskId), + apiClient.getTaskLinkedEmployees(taskId), ]); setLinkedMessages(messages); setLinkedEvents(events); setLinkedDocuments(documents); setLinkedRequests(requests); setLinkedProcurementRequests(procurementRequests); + setLinkedEmployees(employees); } catch (linksError) { setError(getTaskError(linksError, "Не удалось загрузить связанные объекты")); } finally { @@ -1539,7 +1658,7 @@ export default function TasksPage() { () => activeColumns.find((column) => column.id === viewTask?.column) || null, [activeColumns, viewTask?.column], ); - const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length; + const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length; const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount; const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length; @@ -1559,6 +1678,7 @@ export default function TasksPage() { setLinkedDocuments([]); setLinkedRequests([]); setLinkedProcurementRequests([]); + setLinkedEmployees([]); setTaskActivities([]); setTaskComments([]); setCommentDraft(""); @@ -1606,6 +1726,7 @@ export default function TasksPage() { setLinkedDocuments([]); setLinkedRequests([]); setLinkedProcurementRequests([]); + setLinkedEmployees([]); setTaskComments([]); setTaskActivities([]); setCommentDraft(""); @@ -1626,6 +1747,7 @@ export default function TasksPage() { setLinkedDocuments([]); setLinkedRequests([]); setLinkedProcurementRequests([]); + setLinkedEmployees([]); setTaskComments([]); setTaskActivities([]); setLinkedObjectsOpen(false); @@ -2041,6 +2163,23 @@ export default function TasksPage() { } }, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]); + const unlinkEmployeeFromViewedTask = useCallback(async (linkId: number) => { + if (!board || !viewTask || saving) return; + + setSaving(true); + setError(null); + try { + await apiClient.unlinkTaskEmployee(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); @@ -2566,9 +2705,14 @@ export default function TasksPage() {
-

- {viewTask.title} -

+
+ + #{viewTask.id} + +

+ {viewTask.title} +

+
{viewTaskColumn ? (
@@ -2862,6 +3006,14 @@ export default function TasksPage() { disabled={saving} /> ))} + {linkedEmployees.map((link) => ( + + ))} ) : (
diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index 929d302b..28ec459b 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -8,6 +8,7 @@ import { BriefcaseBusiness, ChevronRight, History, + Link2, Mail, MessageCircle, Pencil, @@ -16,6 +17,8 @@ import { import { AppShell } from "../../../components/AppShell"; import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCard"; +import { EmployeeTaskLinks } from "@/components/employees/EmployeeTaskLinks"; +import TaskLinkPill from "@/components/tasks/TaskLinkPill"; import ChangeUserPositionModal from "@/components/users/ChangeUserPositionModal"; import EditUserProfileModal from "@/components/users/EditUserProfileModal"; import EmployeeActionModal from "@/components/users/EmployeeActionModal"; @@ -91,6 +94,7 @@ export default function UserDetailPage() { positions, positionsError, positionsLoading, + refreshPerson, setActionField, setAvatarFailed, setEditField, @@ -128,6 +132,7 @@ export default function UserDetailPage() { const [skillsSaving, setSkillsSaving] = useState(false); const [skillsError, setSkillsError] = useState(null); const [profileMenuOpen, setProfileMenuOpen] = useState(false); + const [taskLinksOpen, setTaskLinksOpen] = useState(false); const profileMenuRef = useRef(null); useEffect(() => { @@ -362,7 +367,7 @@ export default function UserDetailPage() { ) : null} - {(canEdit || canManageActions) ? ( + {currentUser ? ( <> {canEdit ? ( - {person.phone_number ? ( - - - Позвонить - - ) : null} - + + {person.phone_number ? ( + + + Позвонить + ) : null} ) : undefined @@ -503,6 +515,40 @@ export default function UserDetailPage() { } /> +
+
+
+

Связанные задачи

+

+ Задачи, привязанные к этому сотруднику +

+
+ +
+ {person.linked_tasks && person.linked_tasks.length > 0 ? ( +
+ {person.linked_tasks.map((task) => ( + + ))} +
+ ) : ( +
+

Связанных задач нет

+
+ )} +
+ setActionField(field, value)} onSave={handleSaveAction} /> + + {person && taskLinksOpen ? ( + setTaskLinksOpen(false)} + onLinked={() => void refreshPerson()} + /> + ) : null} ); } diff --git a/frontend/src/components/employees/EmployeeTaskLinks.tsx b/frontend/src/components/employees/EmployeeTaskLinks.tsx new file mode 100644 index 00000000..92e22fd4 --- /dev/null +++ b/frontend/src/components/employees/EmployeeTaskLinks.tsx @@ -0,0 +1,535 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronRight, Link2, Loader2, Plus } from "lucide-react"; +import { toast } from "sonner"; + +import TaskLinkPill from "@/components/tasks/TaskLinkPill"; +import { Modal } from "@/components/ui"; +import { apiClient } from "@/lib/api"; +import { displayUserName } from "@/lib/shared"; +import type { TaskBoard, TaskCard, TaskPriority, User } from "@/types/api"; + +type EmployeeTaskLinksProps = { + employee: User; + variant?: "section" | "dialog"; + open?: boolean; + onClose?: () => void; + onLinked?: () => void; +}; + +const taskPriorityOptions: { value: TaskPriority; label: string }[] = [ + { value: "low", label: "Низкая" }, + { value: "medium", label: "Средняя" }, + { value: "high", label: "Высокая" }, + { value: "critical", label: "Критическая" }, +]; + +function getTaskOptionLabel(task: TaskCard) { + return `#${task.id} - ${task.title}`; +} + +function taskMatchesSearch(task: TaskCard, query: string) { + const normalized = query.trim().toLowerCase(); + if (!normalized) return true; + return [ + String(task.id), + task.title, + task.description || "", + task.assignee ? displayUserName(task.assignee) : "", + ] + .join(" ") + .toLowerCase() + .includes(normalized); +} + +export function EmployeeTaskLinks({ + employee, + variant = "section", + open = false, + onClose, + onLinked, +}: EmployeeTaskLinksProps) { + const [linkedTasks, setLinkedTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [linkModalOpen, setLinkModalOpen] = useState(false); + const [boards, setBoards] = useState([]); + const [employees, setEmployees] = useState([]); + const [boardId, setBoardId] = useState(""); + const [taskId, setTaskId] = useState(""); + const [taskSearch, setTaskSearch] = useState(""); + const [mode, setMode] = useState<"existing" | "create">("existing"); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [detailsOpen, setDetailsOpen] = useState(false); + const [columnId, setColumnId] = useState(""); + const [assigneeId, setAssigneeId] = useState(""); + const [priority, setPriority] = useState("medium"); + const [dueDate, setDueDate] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const selectedBoard = boards.find((board) => board.id === boardId) || null; + const selectedTask = (selectedBoard?.tasks || []).find((task) => task.id === taskId) || null; + const selectedColumn = ( + (selectedBoard?.columns || []).find((column) => column.id === columnId) || + (selectedBoard?.columns || []).find((column) => !column.is_archived) || + null + ); + const linkedTaskIds = useMemo( + () => new Set(linkedTasks.map((task) => task.id)), + [linkedTasks], + ); + const dialogMode = variant === "dialog"; + const resolvedModalOpen = dialogMode ? open : linkModalOpen; + const employeeName = displayUserName(employee); + + const loadLinkedTasks = useCallback(async () => { + setLoading(true); + try { + const tasks = await apiClient.getEmployeeLinkedTasks(employee.id); + setLinkedTasks(tasks); + } catch (loadError) { + console.error("Ошибка загрузки связанных задач:", loadError); + toast.error("Не удалось загрузить связанные задачи"); + } finally { + setLoading(false); + } + }, [employee.id]); + + useEffect(() => { + void loadLinkedTasks(); + }, [loadLinkedTasks]); + + const resetForm = () => { + setBoardId(""); + setTaskId(""); + setTaskSearch(""); + setMode("existing"); + setTitle(""); + setDescription(""); + setDetailsOpen(false); + setColumnId(""); + setAssigneeId(""); + setPriority("medium"); + setDueDate(""); + setError(null); + }; + + const prepareLinkModal = useCallback(async () => { + setSaving(true); + setError(null); + setTitle(`Задача по сотруднику: ${employeeName}`); + setDescription(employee.position?.name ? `Должность: ${employee.position.name}` : ""); + try { + const [boardsResponse, employeesResponse] = await Promise.all([ + apiClient.getTaskBoards(), + apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }), + ]); + const nextBoards = (boardsResponse.results || boardsResponse || []) as TaskBoard[]; + const nextEmployees = (employeesResponse.results || employeesResponse || []) as User[]; + const firstBoard = nextBoards.find((board) => (board.tasks || []).some((task) => !linkedTaskIds.has(task.id))) || nextBoards[0] || null; + const firstTask = (firstBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null; + const firstColumn = firstBoard?.columns?.find((column) => !column.is_archived) || null; + + setBoards(nextBoards); + setEmployees(nextEmployees); + setBoardId(firstBoard?.id || ""); + setTaskId(firstTask?.id || ""); + setTaskSearch(""); + setColumnId(firstColumn?.id || ""); + setMode(firstTask ? "existing" : "create"); + setDetailsOpen(false); + setAssigneeId(employee.id); + setPriority("medium"); + setDueDate(""); + } catch (openError) { + setError(openError instanceof Error ? openError.message : "Не удалось загрузить задачи"); + } finally { + setSaving(false); + } + }, [employee.id, employee.position?.name, employeeName, linkedTaskIds]); + + const openLinkModal = async () => { + setLinkModalOpen(true); + await prepareLinkModal(); + }; + + const closeLinkModal = () => { + if (saving) return; + if (dialogMode) { + onClose?.(); + } else { + setLinkModalOpen(false); + } + resetForm(); + }; + + useEffect(() => { + if (!dialogMode) return; + if (open) { + void prepareLinkModal(); + } else { + resetForm(); + } + }, [dialogMode, open, prepareLinkModal]); + + const handleBoardChange = (nextBoardId: number | "") => { + setBoardId(nextBoardId); + const nextBoard = boards.find((board) => board.id === nextBoardId) || null; + const nextTask = (nextBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null; + setTaskId(nextTask?.id || ""); + setTaskSearch(""); + setColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || ""); + if (!nextTask) { + setMode("create"); + } + }; + + const handleTaskSearchChange = (value: string) => { + setTaskSearch(value); + const nextTask = availableTasks.find((task) => taskMatchesSearch(task, value)); + setTaskId(nextTask?.id || ""); + }; + + const saveLink = async () => { + if (!selectedBoard) return; + if (mode === "existing" && !selectedTask) return; + if (mode === "create" && (!title.trim() || !selectedColumn)) return; + + setSaving(true); + setError(null); + try { + let taskToLink = selectedTask; + if (mode === "create") { + taskToLink = await apiClient.createTask({ + board: selectedBoard.id, + column: selectedColumn?.id, + title: title.trim(), + description: description.trim(), + assignee_id: assigneeId || null, + priority, + due_date: dueDate || null, + label_ids: [], + }); + } + + if (!taskToLink) return; + await apiClient.linkTaskEmployee(taskToLink.id, employee.id); + await loadLinkedTasks(); + toast.success("Сотрудник связан с задачей"); + onLinked?.(); + if (dialogMode) { + onClose?.(); + } else { + setLinkModalOpen(false); + } + resetForm(); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : "Не удалось связать сотрудника с задачей"); + } finally { + setSaving(false); + } + }; + + const availableTasks = (selectedBoard?.tasks || []).filter((task) => !linkedTaskIds.has(task.id)); + const filteredAvailableTasks = availableTasks.filter((task) => taskMatchesSearch(task, taskSearch)); + + const modal = ( + + + +
+ )} + > +
+
+

Сотрудник

+

+ {employeeName} +

+

+ {[employee.position?.name, employee.email].filter(Boolean).join(" · ")} +

+
+ + + +
+ + +
+ + {mode === "existing" ? ( +
+ + +
+ ) : ( +
+ +