import mimetypes from pathlib import Path from urllib.parse import urlsplit from django.contrib.contenttypes.models import ContentType from django.contrib.auth import get_user_model from django.db import transaction from django.db.models import ( BooleanField, Count, DateTimeField, IntegerField, Max, OuterRef, Prefetch, Q, Subquery, Value, ) from django.db.models.functions import Coalesce from django.http import FileResponse from django.shortcuts import get_object_or_404 from django.utils import timezone from attendance.models import AttendanceRecord from communications import comments_helpers from communications.models import Message from communications.utils import user_can_access_chat from documents.models import Document from feed.models import Post from guests.models import Guest, GuestVisit from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest from rest_framework import filters, parsers, 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, user_can_access_document, user_can_access_employee, user_can_access_employee_request, user_can_access_feed_post, user_can_access_guest, user_can_access_guest_visit, user_can_access_attendance_record, user_can_access_procurement_request, ) from tasks.automation.engine import conditions_match, execute_automation from tasks.automation.schema import automation_catalog from tasks.comment_resources import add_comment_links_to_task from tasks.media import ensure_task_attachment_thumbnail from tasks.models import ( Task, TaskActivity, TaskActivityAction, TaskActivityObjectKind, TaskAttachment, TaskAutomation, TaskAutomationKind, TaskAutomationRun, TaskAutomationRunStatus, TaskBoard, TaskBoardGroup, TaskBoardPlacement, TaskColumn, TaskColumnRow, TaskChecklistItem, TaskCover, TaskCoverType, TaskExternalLink, TaskLabel, TaskLinkedObject, TaskLinkedObjectKind, TaskUserSettings, ) from tasks.notifications import ( notify_board_members_added, notify_task_activity_change, notify_task_comment, notify_task_created, notify_task_deleted, notify_task_moved, notify_task_updated, ) from tasks.notifications.read_service import TaskBoardNotificationReadService from tasks.realtime import ( get_task_board_recipient_ids, send_task_board_update, ) from .serializers import ( EmployeeBriefSerializer, TaskActivitySerializer, TaskAttachmentSerializer, TaskAutomationRunSerializer, TaskAutomationSerializer, TaskBoardSerializer, TaskBoardGroupSerializer, TaskBoardSummarySerializer, TaskChecklistItemSerializer, TaskColumnSerializer, TaskColumnRowSerializer, TaskExternalLinkSerializer, TaskLabelSerializer, TaskLinkedCalendarEventSerializer, TaskLinkedAttendanceRecordSerializer, TaskLinkedDocumentSerializer, TaskLinkedEmployeeSerializer, TaskLinkedGuestSerializer, TaskLinkedGuestVisitSerializer, TaskLinkedMessageSerializer, TaskLinkedPostSerializer, TaskLinkedProcurementRequestSerializer, TaskLinkedRequestSerializer, TaskSerializer, create_default_columns, get_attendance_record_content_type, get_calendar_event_content_type, get_document_content_type, get_employee_content_type, get_guest_content_type, get_guest_visit_content_type, get_message_content_type, get_post_content_type, get_procurement_request_content_type, get_request_content_type, ) Employee = get_user_model() def create_task_activity( task, actor, action, *, object_kind="", object_id=None, metadata=None, ): if actor and getattr(actor, "is_authenticated", False): task.participants.add(actor) TaskActivity.objects.create( task=task, actor=actor if getattr(actor, "is_authenticated", False) else None, action=action, object_kind=object_kind or "", object_id=object_id, metadata=metadata or {}, ) notify_task_activity_change( task, actor, action, object_kind=object_kind or "", metadata=metadata or {}, ) def normalize_column_positions(column, row=None): task_ids = list( column.tasks.filter(row=row) .order_by("position", "-created_at", "id") .values_list( "id", flat=True, ) ) for index, task_id in enumerate(task_ids): Task.objects.filter(id=task_id).update(position=(index + 1) * 1000) def send_task_checklist_update(task, event, item_id, *, cover_cleared=False): extra = { "task_id": task.id, "checklist_total": task.checklist_items.count(), "checklist_completed": task.checklist_items.filter(is_completed=True).count(), } if cover_cleared: extra["cover"] = None send_task_board_update( task.board, event, "checklist_item", item_id, extra=extra, ) class TaskAutomationViewSet(viewsets.ModelViewSet): """Manage shared task automation rules. Automation management is temporarily available to every authenticated user. Rules are system-wide and may target multiple boards, so this broad access must be replaced with a dedicated permission before permission roles are finalized. """ # TODO(tasks-automation-permissions): Replace broad authenticated access with # a dedicated permission/role for managing shared automation rules. permission_classes = [IsAuthenticated] serializer_class = TaskAutomationSerializer filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ["name", "description"] ordering_fields = ["position", "name", "created_at", "last_run_at"] ordering = ["kind", "position", "name", "id"] def get_queryset(self): queryset = ( TaskAutomation.objects.select_related("created_by") .prefetch_related("boards") .annotate(runs_count=Count("runs", distinct=True)) ) kind = self.request.query_params.get("kind") if kind: queryset = queryset.filter(kind=kind) if self.request.query_params.get("active") in {"true", "false"}: queryset = queryset.filter( is_active=self.request.query_params["active"] == "true" ) if board_id := self.request.query_params.get("board"): queryset = queryset.filter( Q(applies_to_all_boards=True) | Q(boards=board_id) ).distinct() return queryset def perform_create(self, serializer): serializer.save(created_by=self.request.user) @action(detail=False, methods=["get"]) def catalog(self, request): return Response(automation_catalog()) def _accessible_task(self, request, task_id): return get_object_or_404( Task.objects.select_related( "board", "column", "created_by", "assignee" ).filter( board__in=TaskBoard.objects.filter(task_board_access_q(request.user)) ), id=task_id, ) @action(detail=False, methods=["get"]) def buttons(self, request): task_id = request.query_params.get("task") if not task_id: return Response( {"task": ["Укажите задачу."]}, status=status.HTTP_400_BAD_REQUEST, ) task = self._accessible_task(request, task_id) queryset = ( self.get_queryset() .filter( kind=TaskAutomationKind.BUTTON, is_active=True, ) .filter(Q(applies_to_all_boards=True) | Q(boards=task.board_id)) .distinct() ) eligible = [ automation for automation in queryset if conditions_match(task, automation.conditions) ] return Response(self.get_serializer(eligible, many=True).data) @action(detail=True, methods=["post"]) def run(self, request, pk=None): automation = self.get_object() if automation.kind != TaskAutomationKind.BUTTON or not automation.is_active: return Response( {"detail": "Эту автоматизацию нельзя запустить вручную."}, status=status.HTTP_400_BAD_REQUEST, ) task_id = request.data.get("task") if not task_id: return Response( {"task": ["Укажите задачу."]}, status=status.HTTP_400_BAD_REQUEST, ) task = self._accessible_task(request, task_id) run = execute_automation( automation, task, source="manual", actor=request.user, ) if not run: return Response( {"detail": "Автоматизация не была запущена."}, status=status.HTTP_409_CONFLICT, ) if run.status == TaskAutomationRunStatus.SKIPPED: return Response( {"detail": run.error or "Условия автоматизации не выполнены."}, status=status.HTTP_409_CONFLICT, ) return Response(TaskAutomationRunSerializer(run).data) @action(detail=True, methods=["get"]) def runs(self, request, pk=None): automation = self.get_object() queryset = TaskAutomationRun.objects.filter( automation=automation, ).select_related("automation", "task", "actor")[:100] return Response(TaskAutomationRunSerializer(queryset, many=True).data) class TaskBoardViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = TaskBoardSerializer def get_serializer_class(self): if self.action == "list" and self.request.query_params.get("view") == "summary": return TaskBoardSummarySerializer return super().get_serializer_class() def get_queryset(self): current_placement = TaskBoardPlacement.objects.filter( user=self.request.user, board_id=OuterRef("pk"), ) queryset = ( TaskBoard.objects.filter(is_archived=False) .filter(task_board_access_q(self.request.user)) .select_related("created_by") .prefetch_related("members", "departments") .annotate( current_user_group_id=Subquery( current_placement.values("group_id")[:1], output_field=IntegerField(), ), current_user_is_pinned=Coalesce( Subquery( current_placement.values("is_pinned")[:1], output_field=BooleanField(), ), Value(False), ), current_user_pinned_at=Subquery( current_placement.values("pinned_at")[:1], output_field=DateTimeField(), ), current_user_notifications_enabled=Coalesce( Subquery( current_placement.values("notifications_enabled")[:1], output_field=BooleanField(), ), Value(True), ), tasks_count=Count("tasks", distinct=True), open_tasks_count=Count( "tasks", filter=Q(tasks__completed_at__isnull=True), distinct=True, ), completed_tasks_count=Count( "tasks", filter=Q(tasks__completed_at__isnull=False), distinct=True, ), ) .distinct() .order_by( "-current_user_is_pinned", "-current_user_pinned_at", "name", "id", ) ) if not ( self.action == "list" and self.request.query_params.get("view") == "summary" ): queryset = queryset.prefetch_related( Prefetch( "columns", queryset=TaskColumn.objects.annotate( tasks_count=Count("tasks", distinct=True), subcolumns_count=Count( "subcolumns", filter=Q(subcolumns__is_archived=False), distinct=True, ), ).order_by("parent_id", "position", "id"), ), Prefetch( "tasks", queryset=Task.objects.select_related( "created_by", "assignee", "column", "row", "cover", "cover__attachment", "cover__attachment__uploaded_by", "cover__comment", "cover__comment__author", "cover__external_link", "cover__external_link__created_by", "cover__linked_object", "cover__linked_object__content_type", ) .prefetch_related( "participants", "labels", Prefetch( "linked_objects", queryset=TaskLinkedObject.objects.only( "id", "task_id", "kind", ), ), Prefetch( "external_links", queryset=TaskExternalLink.objects.only( "id", "task_id", ), ), Prefetch( "checklist_items", queryset=TaskChecklistItem.objects.filter( task__cover__kind=TaskCoverType.CHECKLIST, ).only( "id", "task_id", "title", "is_completed", "position", ), ), Prefetch( "cover__comment__task_attachments", queryset=TaskAttachment.objects.select_related( "uploaded_by" ), ), ) .annotate( comments_count=Coalesce( Subquery( Message.objects.filter( chat__type="comments", chat__context_content_type=( ContentType.objects.get_for_model(Task) ), chat__context_object_id=OuterRef("pk"), is_deleted=False, ) .values("chat") .annotate(count=Count("id")) .values("count"), output_field=IntegerField(), ), Value(0), ), attachments_count=Coalesce( Subquery( TaskAttachment.objects.filter(task_id=OuterRef("pk")) .values("task_id") .annotate(count=Count("id")) .values("count"), output_field=IntegerField(), ), Value(0), ), checklist_total=Coalesce( Subquery( TaskChecklistItem.objects.filter(task_id=OuterRef("pk")) .values("task_id") .annotate(count=Count("id")) .values("count"), output_field=IntegerField(), ), Value(0), ), checklist_completed=Coalesce( Subquery( TaskChecklistItem.objects.filter( task_id=OuterRef("pk"), is_completed=True, ) .values("task_id") .annotate(count=Count("id")) .values("count"), output_field=IntegerField(), ), Value(0), ), ), ), ) return queryset def perform_create(self, serializer): board = serializer.save(created_by=self.request.user) create_default_columns(board) notify_board_members_added( board, self.request.user, board.members.filter(is_active=True), ) send_task_board_update(board, "created", "board", board.id) def perform_update(self, serializer): board = self.get_object() previous_recipient_ids = get_task_board_recipient_ids(board) previous_member_ids = set(board.members.values_list("id", flat=True)) board = serializer.save() added_member_ids = set(board.members.values_list("id", flat=True)) - ( previous_member_ids ) if added_member_ids: notify_board_members_added( board, self.request.user, Employee.objects.filter(id__in=added_member_ids, is_active=True), ) recipient_ids = previous_recipient_ids | get_task_board_recipient_ids(board) send_task_board_update( board, "updated", "board", board.id, recipient_ids=recipient_ids, ) def perform_destroy(self, instance): recipient_ids = get_task_board_recipient_ids(instance) board_id = instance.id # Task.column is protected so an occupied column cannot be removed on its # own. When the whole board is deleted, remove its tasks first so that # Django can cascade the remaining board structure without raising # ProtectedError. with transaction.atomic(): Task.objects.filter(board_id=board_id).delete() instance.delete() send_task_board_update( None, "deleted", "board", board_id, recipient_ids=recipient_ids, board_id=board_id, ) @action(detail=True, methods=["get"], url_path="participant-candidates") def participant_candidates(self, request, pk=None): board = self.get_object() recipient_ids = get_task_board_recipient_ids(board) candidates = Employee.objects.filter( id__in=recipient_ids, is_active=True, ).order_by("last_name", "first_name", "id") return Response( EmployeeBriefSerializer( candidates, many=True, context=self.get_serializer_context(), ).data ) @action(detail=False, methods=["get"], url_path="default") def default(self, request): queryset = self.get_queryset() settings_obj = ( TaskUserSettings.objects.select_related("default_board") .filter(user=request.user) .first() ) board = None if settings_obj and settings_obj.default_board_id: board = queryset.filter(id=settings_obj.default_board_id).first() if board is None: settings_obj.default_board = None settings_obj.save(update_fields=["default_board", "updated_at"]) if board is None: board = queryset.order_by("id").first() if not board: board = TaskBoard.objects.create( name="Рабочая доска", description="", created_by=request.user, ) create_default_columns(board) serializer = self.get_serializer(self.get_queryset().get(id=board.id)) return Response(serializer.data) @action(detail=True, methods=["post"], url_path="set-default") def set_default(self, request, pk=None): board = self.get_object() TaskUserSettings.objects.update_or_create( user=request.user, defaults={"default_board": board}, ) serializer = self.get_serializer(self.get_queryset().get(id=board.id)) return Response(serializer.data) @action(detail=True, methods=["post"], url_path="move-to-group") def move_to_group(self, request, pk=None): board = self.get_object() group_id = request.data.get("group") if group_id in (None, ""): placement = TaskBoardPlacement.objects.filter( user=request.user, board=board, ).first() if placement: placement.group = None if ( not placement.is_pinned and placement.notifications_enabled and not placement.view_state ): placement.delete() else: placement.save(update_fields=["group", "updated_at"]) return Response({"board": board.id, "group_id": None}) try: group_id = int(group_id) except (TypeError, ValueError): return Response( {"group": ["Некорректная группа."]}, status=status.HTTP_400_BAD_REQUEST, ) group = get_object_or_404( TaskBoardGroup, id=group_id, user=request.user, ) TaskBoardPlacement.objects.update_or_create( user=request.user, board=board, defaults={"group": group}, ) return Response({"board": board.id, "group_id": group.id}) @action(detail=True, methods=["post"]) def pin(self, request, pk=None): board = self.get_object() placement, _ = TaskBoardPlacement.objects.get_or_create( user=request.user, board=board, defaults={"group": None}, ) placement.is_pinned = not placement.is_pinned placement.pinned_at = timezone.now() if placement.is_pinned else None is_pinned = placement.is_pinned if ( not placement.is_pinned and placement.group_id is None and placement.notifications_enabled and not placement.view_state ): placement.delete() else: placement.save(update_fields=["is_pinned", "pinned_at", "updated_at"]) return Response({"ok": True, "is_pinned": is_pinned}) @action(detail=True, methods=["post"]) def notifications(self, request, pk=None): board = self.get_object() placement, _ = TaskBoardPlacement.objects.get_or_create( user=request.user, board=board, defaults={"group": None}, ) placement.notifications_enabled = not placement.notifications_enabled notifications_enabled = placement.notifications_enabled if ( placement.notifications_enabled and placement.group_id is None and not placement.is_pinned and not placement.view_state ): placement.delete() else: placement.save(update_fields=["notifications_enabled", "updated_at"]) return Response({"ok": True, "notifications_enabled": notifications_enabled}) @action(detail=True, methods=["patch"], url_path="view-state") def view_state(self, request, pk=None): board = self.get_object() scope = request.data.get("scope") collapsed = request.data.get("collapsed") try: object_id = int(request.data.get("object_id")) except (TypeError, ValueError): object_id = None state_keys = { "column": "hidden_column_ids", "base": "collapsed_base_column_ids", "row": "collapsed_row_ids", } if scope not in state_keys: return Response( {"scope": ["Допустимые значения: column, base, row."]}, status=status.HTTP_400_BAD_REQUEST, ) if object_id is None: return Response( {"object_id": ["Укажите корректный идентификатор."]}, status=status.HTTP_400_BAD_REQUEST, ) if not isinstance(collapsed, bool): return Response( {"collapsed": ["Укажите логическое значение."]}, status=status.HTTP_400_BAD_REQUEST, ) if scope == "row": object_exists = TaskColumnRow.objects.filter( id=object_id, column__board=board, column__is_archived=False, ).exists() else: object_exists = TaskColumn.objects.filter( id=object_id, board=board, parent__isnull=True, is_archived=False, ).exists() if not object_exists: return Response( {"object_id": ["Элемент не принадлежит этой доске."]}, status=status.HTTP_400_BAD_REQUEST, ) with transaction.atomic(): placement, _ = TaskBoardPlacement.objects.select_for_update().get_or_create( user=request.user, board=board, defaults={"group": None}, ) state = ( placement.view_state.copy() if isinstance(placement.view_state, dict) else {} ) key = state_keys[scope] values = {value for value in state.get(key, []) if isinstance(value, int)} if collapsed: values.add(object_id) else: values.discard(object_id) state[key] = sorted(values) state = {name: value for name, value in state.items() if value} placement.view_state = state if ( not state and placement.group_id is None and not placement.is_pinned and placement.notifications_enabled ): placement.delete() else: placement.save(update_fields=["view_state", "updated_at"]) return Response( { "hidden_column_ids": state.get("hidden_column_ids", []), "collapsed_base_column_ids": state.get("collapsed_base_column_ids", []), "collapsed_row_ids": state.get("collapsed_row_ids", []), } ) @action(detail=True, methods=["post"], url_path="notifications/read") def mark_notifications_read(self, request, pk=None): board = self.get_object() result = TaskBoardNotificationReadService().mark_board_notifications_as_read( user=request.user, board=board, ) return Response( { "status": "success", "count": result.count, "notification_ids": result.notification_ids, } ) class TaskBoardGroupViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = TaskBoardGroupSerializer def get_queryset(self): return ( TaskBoardGroup.objects.filter(user=self.request.user) .annotate(boards_count=Count("board_placements", distinct=True)) .order_by("position", "name", "id") ) def perform_create(self, serializer): max_position = ( self.get_queryset().aggregate(value=Max("position"))["value"] or 0 ) serializer.save(user=self.request.user, position=max_position + 1000) @action(detail=True, methods=["post"], url_path="set-default") def set_default(self, request, pk=None): group = self.get_object() settings_obj, _ = TaskUserSettings.objects.get_or_create(user=request.user) is_default = settings_obj.default_group_id != group.id settings_obj.default_group = group if is_default else None settings_obj.save(update_fields=["default_group", "updated_at"]) serializer = self.get_serializer(group) return Response( { "group": serializer.data, "is_default_for_current_user": is_default, } ) def perform_destroy(self, instance): user = self.request.user instance.delete() TaskBoardPlacement.objects.filter( user=user, group__isnull=True, is_pinned=False, notifications_enabled=True, view_state={}, ).delete() class TaskColumnViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = TaskColumnSerializer def get_queryset(self): queryset = ( TaskColumn.objects.filter(board__is_archived=False) .filter( board__in=TaskBoard.objects.filter( task_board_access_q(self.request.user) ) ) .select_related("board") .select_related("parent") .annotate( tasks_count=Count("tasks", distinct=True), subcolumns_count=Count( "subcolumns", filter=Q(subcolumns__is_archived=False), distinct=True, ), ) .order_by("board_id", "parent_id", "position", "id") ) board = self.request.query_params.get("board") if board: queryset = queryset.filter(board_id=board) return queryset def perform_create(self, serializer): with transaction.atomic(): parent = serializer.validated_data.get("parent") if parent: parent = TaskColumn.objects.select_for_update().get(pk=parent.pk) elif serializer.validated_data.get("is_done"): board = serializer.validated_data["board"] TaskColumn.objects.select_for_update().filter( board=board, parent__isnull=True, is_done=True, ).update(is_done=False) TaskColumn.objects.filter( board=board, parent__isnull=False, is_done=True, ).update(is_done=False) column = serializer.save(parent=parent) if parent: task_updates = {"column": column} if column.is_done: task_updates["completed_at"] = timezone.now() else: task_updates["completed_at"] = None Task.objects.filter(column=parent).update(**task_updates) send_task_board_update(column.board, "created", "column", column.id) def perform_update(self, serializer): with transaction.atomic(): current_column = TaskColumn.objects.select_for_update().get( pk=serializer.instance.pk ) requested_is_done = serializer.validated_data.get( "is_done", current_column.is_done, ) if current_column.parent_id is None and requested_is_done: TaskColumn.objects.filter( board=current_column.board, parent__isnull=True, is_done=True, ).exclude(pk=current_column.pk).update(is_done=False) TaskColumn.objects.filter( board=current_column.board, parent__isnull=False, is_done=True, ).exclude(parent_id=current_column.pk).update(is_done=False) column = serializer.save() if column.parent_id is None: column.subcolumns.update(is_done=column.is_done) send_task_board_update(column.board, "updated", "column", column.id) def destroy(self, request, *args, **kwargs): column = self.get_object() if column.subcolumns.exists(): return Response( {"detail": "Сначала удалите подколонки этой колонки."}, status=status.HTTP_400_BAD_REQUEST, ) if column.tasks.exists(): return Response( {"detail": ("Переместите задачи в другую колонку перед удалением.")}, status=status.HTTP_400_BAD_REQUEST, ) if ( column.parent_id is None and column.board.columns.filter( is_archived=False, parent__isnull=True, ).count() <= 1 ): return Response( {"detail": "На доске должна остаться хотя бы одна колонка."}, status=status.HTTP_400_BAD_REQUEST, ) return super().destroy(request, *args, **kwargs) def perform_destroy(self, instance): board = instance.board column_id = instance.id instance.delete() send_task_board_update(board, "deleted", "column", column_id) @action(detail=False, methods=["post"], url_path="reorder") def reorder(self, request): """Сохраняет единый для всех участников порядок колонок доски.""" raw_board_id = request.data.get("board") raw_column_ids = request.data.get("column_ids") raw_parent_id = request.data.get("parent") if not raw_board_id: return Response( {"board": ["Укажите доску."]}, status=status.HTTP_400_BAD_REQUEST, ) try: board_id = int(raw_board_id) except (TypeError, ValueError): return Response( {"board": ["Идентификатор доски должен быть числом."]}, status=status.HTTP_400_BAD_REQUEST, ) if not isinstance(raw_column_ids, list) or not raw_column_ids: return Response( {"column_ids": ["Передайте полный порядок активных колонок."]}, status=status.HTTP_400_BAD_REQUEST, ) try: column_ids = [int(column_id) for column_id in raw_column_ids] except (TypeError, ValueError): return Response( {"column_ids": ["Идентификаторы колонок должны быть числами."]}, status=status.HTTP_400_BAD_REQUEST, ) if len(column_ids) != len(set(column_ids)): return Response( {"column_ids": ["Колонки в списке не должны повторяться."]}, status=status.HTTP_400_BAD_REQUEST, ) board = get_object_or_404( TaskBoard.objects.filter( task_board_access_q(request.user), is_archived=False, ), pk=board_id, ) parent_id = None if raw_parent_id not in (None, ""): try: parent_id = int(raw_parent_id) except (TypeError, ValueError): return Response( {"parent": ["Идентификатор родителя должен быть числом."]}, status=status.HTTP_400_BAD_REQUEST, ) get_object_or_404( TaskColumn.objects.filter( board=board, parent__isnull=True, is_archived=False, ), pk=parent_id, ) with transaction.atomic(): columns = list( TaskColumn.objects.select_for_update() .filter( board=board, parent_id=parent_id, is_archived=False, ) .order_by("position", "id") ) columns_by_id = {column.id: column for column in columns} if set(column_ids) != set(columns_by_id): return Response( { "column_ids": [ "Список должен содержать все соседние активные колонки." ] }, status=status.HTTP_400_BAD_REQUEST, ) updated_at = timezone.now() ordered_columns = [] for index, column_id in enumerate(column_ids, start=1): column = columns_by_id[column_id] column.position = index * 1000 column.updated_at = updated_at ordered_columns.append(column) TaskColumn.objects.bulk_update( ordered_columns, ["position", "updated_at"], ) send_task_board_update( board, "reordered", "column", extra={"column_ids": column_ids}, ) return Response( { "board": board.id, "parent": parent_id, "column_ids": column_ids, } ) class TaskColumnRowViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = TaskColumnRowSerializer def get_queryset(self): queryset = ( TaskColumnRow.objects.filter( column__board__is_archived=False, column__board__in=TaskBoard.objects.filter( task_board_access_q(self.request.user) ), ) .select_related("column", "column__board") .annotate(tasks_count=Count("tasks", distinct=True)) .order_by("column_id", "position", "id") ) if column := self.request.query_params.get("column"): queryset = queryset.filter(column_id=column) if board := self.request.query_params.get("board"): queryset = queryset.filter(column__board_id=board) return queryset def perform_create(self, serializer): row = serializer.save() send_task_board_update(row.column.board, "created", "column_row", row.id) def perform_update(self, serializer): row = serializer.save() send_task_board_update(row.column.board, "updated", "column_row", row.id) def perform_destroy(self, instance): board = instance.column.board row_id = instance.id leaf_columns = list( instance.column.subcolumns.filter(is_archived=False).order_by( "position", "id", ) ) or [instance.column] instance.delete() for column in leaf_columns: normalize_column_positions(column, None) send_task_board_update(board, "deleted", "column_row", row_id) class TaskLabelViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = TaskLabelSerializer def get_queryset(self): return TaskLabel.objects.order_by("name", "id") def perform_create(self, serializer): serializer.save() def perform_update(self, serializer): board_ids = list( serializer.instance.tasks.values_list("board_id", flat=True).distinct() ) label = serializer.save() for board in TaskBoard.objects.filter(id__in=board_ids): send_task_board_update(board, "updated", "label", label.id) def perform_destroy(self, instance): board_ids = list(instance.tasks.values_list("board_id", flat=True).distinct()) label_id = instance.id instance.delete() for board in TaskBoard.objects.filter(id__in=board_ids): send_task_board_update(board, "deleted", "label", label_id) class TaskViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = TaskSerializer filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ["title", "description"] ordering_fields = ["position", "created_at", "updated_at", "due_date"] 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( board__in=TaskBoard.objects.filter( task_board_access_q(self.request.user) ) ) .select_related( "board", "column", "row", "created_by", "assignee", "cover", "cover__attachment", "cover__attachment__uploaded_by", "cover__comment", "cover__comment__author", "cover__external_link", "cover__external_link__created_by", "cover__linked_object", "cover__linked_object__content_type", ) .prefetch_related( "labels", "participants", "checklist_items", Prefetch( "cover__comment__task_attachments", queryset=TaskAttachment.objects.select_related("uploaded_by"), ), ) .annotate( linked_posts_count=Count( "linked_objects", filter=Q(linked_objects__kind=TaskLinkedObjectKind.POST), distinct=True, ), linked_messages_count=Count( "linked_objects", filter=Q(linked_objects__kind=TaskLinkedObjectKind.MESSAGE), distinct=True, ), linked_events_count=Count( "linked_objects", filter=Q(linked_objects__kind=TaskLinkedObjectKind.CALENDAR_EVENT), 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_procurement_requests_count=Count( "linked_objects", filter=Q( linked_objects__kind=(TaskLinkedObjectKind.PROCUREMENT_REQUEST), ), distinct=True, ), linked_employees_count=Count( "linked_objects", filter=Q( linked_objects__kind=TaskLinkedObjectKind.EMPLOYEE, ), distinct=True, ), linked_guests_count=Count( "linked_objects", filter=Q( linked_objects__kind=TaskLinkedObjectKind.GUEST, ), distinct=True, ), linked_guest_visits_count=Count( "linked_objects", filter=Q( linked_objects__kind=TaskLinkedObjectKind.GUEST_VISIT, ), distinct=True, ), linked_attendance_records_count=Count( "linked_objects", filter=Q( linked_objects__kind=(TaskLinkedObjectKind.ATTENDANCE_RECORD), ), distinct=True, ), linked_objects_count=( Count("linked_objects", distinct=True) + Count("external_links", distinct=True) ), comments_count=Coalesce( Subquery(comments_subquery, output_field=IntegerField()), Value(0), ), attachments_count=Count("attachments", distinct=True), checklist_total=Count("checklist_items", distinct=True), checklist_completed=Count( "checklist_items", filter=Q(checklist_items__is_completed=True), distinct=True, ), ) ) params = self.request.query_params if board := params.get("board"): queryset = queryset.filter(board_id=board) if column := params.get("column"): queryset = queryset.filter(column_id=column) if row := params.get("row"): queryset = queryset.filter(row_id=row) if assignee := params.get("assignee"): queryset = queryset.filter(assignee_id=assignee) if priority := params.get("priority"): queryset = queryset.filter(priority=priority) if params.get("unassigned") == "true": queryset = queryset.filter(assignee__isnull=True) return queryset def perform_create(self, serializer): task = serializer.save(created_by=self.request.user) create_task_activity( task, self.request.user, TaskActivityAction.CREATED, metadata={"column": task.column.name}, ) notify_task_created(task, self.request.user) send_task_board_update(task.board, "created", "task", task.id) def perform_update(self, serializer): instance = serializer.instance previous = { "title": instance.title, "description": instance.description, "assignee_id": instance.assignee_id, "priority": instance.priority, "due_date": instance.due_date.isoformat() if instance.due_date else None, "row_id": instance.row_id, "label_ids": sorted(instance.labels.values_list("id", flat=True)), "participant_ids": sorted( instance.participants.values_list("id", flat=True) ), } task = serializer.save() current = { "title": task.title, "description": task.description, "assignee_id": task.assignee_id, "priority": task.priority, "due_date": task.due_date.isoformat() if task.due_date else None, "row_id": task.row_id, "label_ids": sorted(task.labels.values_list("id", flat=True)), "participant_ids": sorted(task.participants.values_list("id", flat=True)), } changed_fields = [ field for field, value in current.items() if previous[field] != value ] if changed_fields: create_task_activity( task, self.request.user, TaskActivityAction.UPDATED, metadata={ "fields": changed_fields, "previous": previous, "current": current, }, ) notify_task_updated(task, self.request.user, previous, current) send_task_board_update(task.board, "updated", "task", task.id) def perform_destroy(self, instance): board = instance.board task_id = instance.id notify_task_deleted(instance, self.request.user) instance.delete() send_task_board_update(board, "deleted", "task", task_id) def _move_task(self, task, column, row, position, actor): old_column = task.column old_row = task.row if position in (None, ""): max_position = ( Task.objects.filter(column=column, row=row) .exclude(id=task.id) .aggregate(Max("position"))["position__max"] or 0 ) position = max_position + 1000 task.column = column task.row = row task.position = position task.save( update_fields=["column", "row", "position", "completed_at", "updated_at"] ) normalize_column_positions(column, row) if old_column.id != column.id or old_row != row: normalize_column_positions(old_column, old_row) task.refresh_from_db() activity_metadata = { "from_column_id": old_column.id, "from_column": old_column.name, "to_column_id": column.id, "to_column": column.name, } if old_row or row: activity_metadata.update( { "from_row_id": old_row.id if old_row else None, "from_row": old_row.name if old_row else None, "to_row_id": row.id if row else None, "to_row": row.name if row else None, } ) create_task_activity( task, actor, TaskActivityAction.MOVED, metadata=activity_metadata, ) notify_task_moved(task, actor, old_column, column) send_task_board_update( task.board, "moved", "task", task.id, extra={"column_id": task.column_id, "row_id": task.row_id}, ) return task @action(detail=True, methods=["post"]) def move(self, request, pk=None): task = self.get_object() column_id = request.data.get("column") raw_row_id = request.data.get("row") if "row" in request.data else None position = request.data.get("position") if not column_id: return Response( {"column": ["Это поле обязательно."]}, status=status.HTTP_400_BAD_REQUEST, ) try: column = TaskColumn.objects.get( id=column_id, board_id=task.board_id, is_archived=False, ) except TaskColumn.DoesNotExist: return Response( {"column": ["Колонка не найдена на этой доске."]}, status=status.HTTP_400_BAD_REQUEST, ) if column.subcolumns.filter(is_archived=False).exists(): return Response( {"column": ["Выберите подколонку для размещения задачи."]}, status=status.HTTP_400_BAD_REQUEST, ) row = None if raw_row_id not in (None, ""): try: row_id = int(raw_row_id) except (TypeError, ValueError): return Response( {"row": ["Идентификатор дорожки должен быть числом."]}, status=status.HTTP_400_BAD_REQUEST, ) top_level_column_id = column.parent_id or column.id try: row = TaskColumnRow.objects.get( id=row_id, column_id=top_level_column_id, ) except TaskColumnRow.DoesNotExist: return Response( {"row": ["Дорожка не принадлежит основной колонке задачи."]}, status=status.HTTP_400_BAD_REQUEST, ) if position in (None, ""): position = None else: try: position = int(position) except (TypeError, ValueError): return Response( {"position": ["Позиция должна быть числом."]}, status=status.HTTP_400_BAD_REQUEST, ) task = self._move_task(task, column, row, position, request.user) serializer = self.get_serializer(task) return Response(serializer.data) @action(detail=True, methods=["post"]) def claim(self, request, pk=None): visible_task = self.get_object() with transaction.atomic(): task = ( Task.objects.select_for_update(of=("self",)) .select_related("board", "column", "assignee") .get(id=visible_task.id) ) if task.completed_at or task.column.is_done: return Response( {"detail": "Нельзя взять в работу завершённую задачу."}, status=status.HTTP_400_BAD_REQUEST, ) if task.assignee_id: if task.assignee_id == request.user.id: return Response(self.get_serializer(task).data) return Response( {"detail": "У задачи уже есть исполнитель."}, status=status.HTTP_409_CONFLICT, ) task.assignee = request.user task.save(update_fields=["assignee", "updated_at"]) task.participants.add(request.user) create_task_activity( task, request.user, TaskActivityAction.CLAIMED, metadata={"assignee_id": request.user.id}, ) notify_task_updated( task, request.user, {"assignee_id": None}, {"assignee_id": request.user.id}, ) send_task_board_update( task.board, "claimed", "task", task.id, extra={"assignee_id": request.user.id}, ) return Response(self.get_serializer(task).data) @action(detail=True, methods=["post"]) def complete(self, request, pk=None): task = self.get_object() if task.assignee_id != request.user.id: return Response( {"detail": "Завершить задачу может только назначенный исполнитель."}, status=status.HTTP_403_FORBIDDEN, ) if task.column.is_done: return Response(self.get_serializer(task).data) done_parent = ( TaskColumn.objects.filter( board_id=task.board_id, is_done=True, is_archived=False, parent__isnull=True, ) .order_by("position", "id") .first() ) if done_parent is None: return Response( {"detail": "На доске не настроена завершающая колонка."}, status=status.HTTP_400_BAD_REQUEST, ) done_column = ( done_parent.subcolumns.filter(is_archived=False) .order_by("position", "id") .first() or done_parent ) task = self._move_task(task, done_column, None, None, request.user) return Response(self.get_serializer(task).data) @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", ) serializer = TaskActivitySerializer(queryset, many=True) return Response(serializer.data) @action( detail=True, methods=["get", "post"], url_path="attachments", parser_classes=[parsers.MultiPartParser, parsers.FormParser], ) def attachments(self, request, pk=None): task = self.get_object() if request.method in ("GET", "HEAD"): queryset = task.attachments.select_related("uploaded_by") serializer = TaskAttachmentSerializer( queryset, many=True, context=self.get_serializer_context(), ) return Response(serializer.data) files = request.FILES.getlist("files") if not files and request.FILES.get("file"): files = [request.FILES["file"]] if not files: return Response( {"files": ["Выберите хотя бы один файл."]}, status=status.HTTP_400_BAD_REQUEST, ) created = [] for uploaded in files: file_name = Path(uploaded.name.replace("\\", "/")).name[:255] attachment = TaskAttachment.objects.create( task=task, file=uploaded, file_name=file_name, file_size=uploaded.size, mime_type=(uploaded.content_type or "")[:255], uploaded_by=request.user, ) created.append(attachment) create_task_activity( task, request.user, TaskActivityAction.ATTACHMENT_ADDED, object_id=attachment.id, metadata={"file_name": attachment.file_name}, ) send_task_board_update( task.board, "attachments_updated", "task", task.id, extra={"attachments_count": task.attachments.count()}, ) serializer = TaskAttachmentSerializer( created, many=True, context=self.get_serializer_context(), ) return Response(serializer.data, status=status.HTTP_201_CREATED) @action( detail=True, methods=["get"], url_path=r"attachments/(?P\d+)/download", url_name="attachment-download", ) def attachment_download(self, request, pk=None, attachment_id=None): task = self.get_object() attachment = get_object_or_404( task.attachments, id=attachment_id, ) try: file_handle = attachment.file.open("rb") except (FileNotFoundError, OSError): return Response( {"detail": "Файл не найден в хранилище."}, status=status.HTTP_404_NOT_FOUND, ) content_type = attachment.mime_type or ( mimetypes.guess_type(attachment.file_name)[0] or "application/octet-stream" ) response = FileResponse( file_handle, as_attachment=True, filename=attachment.file_name, content_type=content_type, ) response["X-Content-Type-Options"] = "nosniff" return response @action( detail=True, methods=["get"], url_path=r"attachments/(?P\d+)/thumbnail", url_name="attachment-thumbnail", ) def attachment_thumbnail(self, request, pk=None, attachment_id=None): task = self.get_object() attachment = get_object_or_404( task.attachments, id=attachment_id, ) if not attachment.thumbnail and not ensure_task_attachment_thumbnail( attachment ): return Response( {"detail": "Для файла не удалось создать миниатюру."}, status=status.HTTP_404_NOT_FOUND, ) try: file_handle = attachment.thumbnail.open("rb") except (FileNotFoundError, OSError, ValueError): return Response( {"detail": "Миниатюра не найдена в хранилище."}, status=status.HTTP_404_NOT_FOUND, ) response = FileResponse( file_handle, as_attachment=False, filename=f"task-cover-{attachment.id}.webp", content_type="image/webp", ) response["Cache-Control"] = "private, max-age=31536000, immutable" response["X-Content-Type-Options"] = "nosniff" return response @action( detail=True, methods=["delete"], url_path=r"attachments/(?P\d+)", url_name="attachment-detail", ) def attachment_detail(self, request, pk=None, attachment_id=None): task = self.get_object() attachment = get_object_or_404( task.attachments, id=attachment_id, ) attachment_id = attachment.id file_name = attachment.file_name was_cover = TaskCover.objects.filter( task=task, kind=TaskCoverType.ATTACHMENT, attachment=attachment, ).exists() attachment.delete() create_task_activity( task, request.user, TaskActivityAction.ATTACHMENT_REMOVED, object_id=attachment_id, metadata={"file_name": file_name}, ) send_task_board_update( task.board, "attachments_updated", "task", task.id, extra={ "attachments_count": task.attachments.count(), **({"cover": None} if was_cover else {}), }, ) return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=["post", "delete"], url_path="cover") def cover(self, request, pk=None): task = self.get_object() with transaction.atomic(): locked_task = Task.objects.select_for_update().get(pk=task.pk) previous_cover = TaskCover.objects.filter(task=locked_task).first() previous = ( { "kind": previous_cover.kind, "source_id": ( previous_cover.attachment_id or previous_cover.comment_id or previous_cover.external_link_id or previous_cover.linked_object_id ), } if previous_cover else None ) if request.method == "DELETE": TaskCover.objects.filter(task=locked_task).delete() next_cover = None else: kind = request.data.get("kind") attachment_id = request.data.get("attachment_id") if not kind and attachment_id: kind = TaskCoverType.ATTACHMENT if kind not in TaskCoverType.values: return Response( {"kind": ["Выберите допустимый тип обложки."]}, status=status.HTTP_400_BAD_REQUEST, ) source_id = request.data.get("source_id") if kind != TaskCoverType.CHECKLIST and not (attachment_id or source_id): return Response( {"source_id": ["Укажите источник обложки."]}, status=status.HTTP_400_BAD_REQUEST, ) defaults = { "kind": kind, "attachment": None, "comment": None, "external_link": None, "linked_object": None, "created_by": request.user, } if kind == TaskCoverType.ATTACHMENT: source_id = attachment_id or source_id attachment = get_object_or_404( locked_task.attachments, id=source_id, ) content_type = attachment.mime_type or ( mimetypes.guess_type(attachment.file_name)[0] or "" ) if not content_type.lower().startswith(("image/", "video/")): return Response( { "source_id": [ "Обложкой может быть изображение или видео." ] }, status=status.HTTP_400_BAD_REQUEST, ) defaults["attachment"] = attachment elif kind == TaskCoverType.CHECKLIST: if not locked_task.checklist_items.exists(): return Response( {"kind": ["В задаче пока нет пунктов чек-листа."]}, status=status.HTTP_400_BAD_REQUEST, ) elif kind == TaskCoverType.COMMENT: comment = get_object_or_404( comments_helpers.get_comments(locked_task), id=source_id, ) defaults["comment"] = comment elif kind == TaskCoverType.EXTERNAL_LINK: defaults["external_link"] = get_object_or_404( locked_task.external_links, id=source_id, ) elif kind == TaskCoverType.LINKED_OBJECT: defaults["linked_object"] = get_object_or_404( locked_task.linked_objects, id=source_id, ) next_cover, _ = TaskCover.objects.update_or_create( task=locked_task, defaults=defaults, ) next_cover.full_clean() current = ( { "kind": next_cover.kind, "source_id": ( next_cover.attachment_id or next_cover.comment_id or next_cover.external_link_id or next_cover.linked_object_id ), } if next_cover else None ) if previous != current: locked_task.save(update_fields=["updated_at"]) if previous != current: create_task_activity( locked_task, request.user, TaskActivityAction.UPDATED, metadata={ "fields": ["cover"], "cover": current, }, ) notify_task_updated( locked_task, request.user, {"cover": previous}, {"cover": current}, ) send_task_board_update( locked_task.board, "updated", "task", locked_task.id, extra={"cover": current}, ) refreshed_task = self.get_queryset().get(pk=locked_task.pk) return Response(self.get_serializer(refreshed_task).data) @action(detail=True, methods=["get", "post"], url_path="checklist") def checklist(self, request, pk=None): task = self.get_object() if request.method in ("GET", "HEAD"): serializer = TaskChecklistItemSerializer( task.checklist_items.select_related("created_by", "completed_by"), many=True, ) return Response(serializer.data) serializer = TaskChecklistItemSerializer(data=request.data) serializer.is_valid(raise_exception=True) position = serializer.validated_data.get("position") if position is None: max_position = ( task.checklist_items.aggregate(Max("position"))["position__max"] or 0 ) position = max_position + 1000 is_completed = serializer.validated_data.get("is_completed", False) item = serializer.save( task=task, created_by=request.user, position=position, completed_by=request.user if is_completed else None, completed_at=timezone.now() if is_completed else None, ) create_task_activity( task, request.user, TaskActivityAction.CHECKLIST_ITEM_ADDED, object_kind=TaskActivityObjectKind.CHECKLIST_ITEM, object_id=item.id, metadata={ "item_title": item.title, "is_completed": item.is_completed, }, ) send_task_checklist_update(task, "checklist_updated", item.id) return Response( TaskChecklistItemSerializer(item).data, status=status.HTTP_201_CREATED, ) @action( detail=True, methods=["patch", "delete"], url_path=r"checklist/(?P\d+)", url_name="checklist-item-detail", ) def checklist_item_detail(self, request, pk=None, item_id=None): task = self.get_object() item = get_object_or_404(task.checklist_items, id=item_id) if request.method == "DELETE": item_id = item.id item_title = item.title with transaction.atomic(): item.delete() task._prefetched_objects_cache.pop("checklist_items", None) cover_cleared = False if not task.checklist_items.exists(): cover_cleared, _ = TaskCover.objects.filter( task=task, kind=TaskCoverType.CHECKLIST, ).delete() cover_cleared = bool(cover_cleared) if cover_cleared: task.save(update_fields=["updated_at"]) create_task_activity( task, request.user, TaskActivityAction.CHECKLIST_ITEM_REMOVED, object_kind=TaskActivityObjectKind.CHECKLIST_ITEM, object_id=item_id, metadata={"item_title": item_title}, ) send_task_checklist_update( task, "checklist_updated", item_id, cover_cleared=cover_cleared, ) return Response(status=status.HTTP_204_NO_CONTENT) serializer = TaskChecklistItemSerializer( item, data=request.data, partial=True, ) serializer.is_valid(raise_exception=True) previous_title = item.title previous_completed = item.is_completed changed_fields = [ field for field, value in serializer.validated_data.items() if getattr(item, field) != value ] if not changed_fields: return Response(TaskChecklistItemSerializer(item).data) completion_metadata = {} if "is_completed" in serializer.validated_data: is_completed = serializer.validated_data["is_completed"] completion_metadata = { "completed_by": request.user if is_completed else None, "completed_at": timezone.now() if is_completed else None, } item = serializer.save(**completion_metadata) if previous_completed != item.is_completed: activity_action = ( TaskActivityAction.CHECKLIST_ITEM_COMPLETED if item.is_completed else TaskActivityAction.CHECKLIST_ITEM_REOPENED ) else: activity_action = TaskActivityAction.CHECKLIST_ITEM_UPDATED create_task_activity( task, request.user, activity_action, object_kind=TaskActivityObjectKind.CHECKLIST_ITEM, object_id=item.id, metadata={ "item_title": item.title, "previous_title": previous_title, "is_completed": item.is_completed, "fields": changed_fields, }, ) send_task_checklist_update(task, "checklist_updated", item.id) return Response(TaskChecklistItemSerializer(item).data) @action(detail=True, methods=["get", "post"], url_path="external-links") def external_links(self, request, pk=None): task = self.get_object() if request.method in ("GET", "HEAD"): serializer = TaskExternalLinkSerializer( task.external_links.select_related("created_by"), many=True, ) return Response(serializer.data) input_serializer = TaskExternalLinkSerializer(data=request.data) input_serializer.is_valid(raise_exception=True) url = input_serializer.validated_data["url"] title = input_serializer.validated_data.get("title", "") default_title = title or urlsplit(url).netloc or url link, created = TaskExternalLink.objects.get_or_create( task=task, url=url, defaults={ "title": default_title, "created_by": request.user, }, ) if not created and title and title != link.title: link.title = title link.save(update_fields=["title"]) if created: create_task_activity( task, request.user, TaskActivityAction.LINKED, object_kind=TaskActivityObjectKind.EXTERNAL_LINK, object_id=link.id, metadata={ "object_label": link.title or link.url, "object_type": "Ссылка", "url": link.url, }, ) send_task_board_update( task.board, "linked", "external_link", link.id, extra={"task_id": task.id, "link_id": link.id}, ) serializer = TaskExternalLinkSerializer(link) return Response( serializer.data, status=(status.HTTP_201_CREATED if created else status.HTTP_200_OK), ) @action( detail=True, methods=["delete"], url_path=r"external-links/(?P\d+)", url_name="external-link-detail", ) def external_link_detail(self, request, pk=None, link_id=None): task = self.get_object() link = get_object_or_404(task.external_links, id=link_id) link_id = link.id title = link.title or link.url url = link.url link.delete() create_task_activity( task, request.user, TaskActivityAction.UNLINKED, object_kind=TaskActivityObjectKind.EXTERNAL_LINK, object_id=link_id, metadata={ "object_label": title, "object_type": "Ссылка", "url": url, }, ) send_task_board_update( task.board, "unlinked", "external_link", link_id, extra={"task_id": task.id, "link_id": link_id}, ) return Response(status=status.HTTP_204_NO_CONTENT) def _comment_payload(self, task, message): attachments = [ attachment for attachment in message.task_attachments.all() if attachment.task_id == task.id ] return { "id": message.id, "task": task.id, "author": EmployeeBriefSerializer(message.author).data, "text": message.content, "is_edited": message.is_edited, "edited_at": message.edited_at, "created_at": message.created_at, "attachments": TaskAttachmentSerializer( attachments, many=True, context=self.get_serializer_context(), ).data, } @action( detail=True, methods=["get", "post"], url_path="comments", parser_classes=[ parsers.JSONParser, parsers.MultiPartParser, parsers.FormParser, ], ) def comments(self, request, pk=None): task = self.get_object() if request.method in ("GET", "HEAD"): messages = comments_helpers.get_comments(task).prefetch_related( Prefetch( "task_attachments", queryset=TaskAttachment.objects.filter(task=task).select_related( "uploaded_by" ), ) ) return Response( [self._comment_payload(task, message) for message in messages] ) text = request.data.get("text", "").strip() files = request.FILES.getlist("files") if not files and request.FILES.get("file"): files = [request.FILES["file"]] if not text and not files: return Response( {"detail": "Введите комментарий или добавьте файл."}, status=status.HTTP_400_BAD_REQUEST, ) with transaction.atomic(): message = comments_helpers.create_comment( obj=task, author=request.user, content=text, ) created_attachments = [] for uploaded in files: file_name = Path(uploaded.name.replace("\\", "/")).name[:255] attachment = TaskAttachment.objects.create( task=task, comment=message, file=uploaded, file_name=file_name, file_size=uploaded.size, mime_type=(uploaded.content_type or "")[:255], uploaded_by=request.user, ) created_attachments.append(attachment) create_task_activity( task, request.user, TaskActivityAction.ATTACHMENT_ADDED, object_id=attachment.id, metadata={ "file_name": attachment.file_name, "comment_id": message.id, }, ) if created_attachments: message.has_attachments = True message.save(update_fields=["has_attachments"]) created_links = add_comment_links_to_task( task=task, text=text, created_by=request.user, ) for link in created_links: create_task_activity( task, request.user, TaskActivityAction.LINKED, object_kind=TaskActivityObjectKind.EXTERNAL_LINK, object_id=link.id, metadata={ "url": link.url, "comment_id": message.id, }, ) create_task_activity( task, request.user, TaskActivityAction.COMMENT_ADDED, object_kind=TaskActivityObjectKind.COMMENT, object_id=message.id, metadata={"comment_text": message.content}, ) notify_task_comment(task, message) send_task_board_update( task.board, "commented", "task", task.id, extra={ "comment_id": message.id, "attachments_count": task.attachments.count(), "linked_objects_count": ( task.linked_objects.count() + task.external_links.count() ), }, ) return Response( self._comment_payload(task, message), status=status.HTTP_201_CREATED, ) @action( detail=True, methods=["patch", "delete"], url_path=r"comments/(?P\d+)", url_name="delete-comment", ) 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 request.method == "PATCH": if message.author_id != request.user.id: return Response( {"detail": "Редактировать комментарий может только его автор."}, status=status.HTTP_403_FORBIDDEN, ) text = request.data.get("text", "").strip() if not text: return Response( {"text": ["Это поле не может быть пустым."]}, status=status.HTTP_400_BAD_REQUEST, ) previous_text = message.content if text == previous_text: return Response(self._comment_payload(task, message)) comments_helpers.update_comment( message, text, edited_by=request.user, ) created_links = add_comment_links_to_task( task=task, text=text, created_by=request.user, ) for link in created_links: create_task_activity( task, request.user, TaskActivityAction.LINKED, object_kind=TaskActivityObjectKind.EXTERNAL_LINK, object_id=link.id, metadata={ "url": link.url, "comment_id": message.id, }, ) create_task_activity( task, request.user, TaskActivityAction.COMMENT_EDITED, object_kind=TaskActivityObjectKind.COMMENT, object_id=message.id, metadata={ "previous_text": previous_text, "comment_text": message.content, }, ) send_task_board_update( task.board, "comment_edited", "task", task.id, extra={"comment_id": message.id}, ) return Response(self._comment_payload(task, message)) 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, ) comment_text = message.content TaskCover.objects.filter( task=task, kind=TaskCoverType.COMMENT, comment=message, ).delete() task.attachments.filter(comment=message).update(comment=None) comments_helpers.delete_comment( message=message, deleted_by=request.user, soft_delete=True, ) create_task_activity( task, request.user, TaskActivityAction.COMMENT_REMOVED, object_kind=TaskActivityObjectKind.COMMENT, object_id=message.id, metadata={"comment_text": comment_text}, ) 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") try: event_id = int(event_id) except (TypeError, ValueError): return Response( {"event_id": ["Укажите ID события."]}, status=status.HTTP_400_BAD_REQUEST, ) try: event = Event.objects.select_related( "calendar", "calendar__binding", "calendar__binding__context_content_type", "rule", "creator", ).get(id=event_id) except Event.DoesNotExist: return Response( {"event_id": ["Событие не найдено."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_calendar_event(request.user, event): return Response( {"detail": "Нет доступа к этому событию."}, status=status.HTTP_403_FORBIDDEN, ) queryset = ( self.get_queryset() .filter( linked_objects__kind=TaskLinkedObjectKind.CALENDAR_EVENT, linked_objects__content_type=get_calendar_event_content_type(), linked_objects__object_id=event.id, ) .distinct() ) 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) @action(detail=False, methods=["get"], url_path="linked-procurement-request-tasks") def linked_procurement_request_tasks(self, request): procurement_request_id = request.query_params.get("procurement_request_id") try: procurement_request_id = int(procurement_request_id) except (TypeError, ValueError): return Response( {"procurement_request_id": ["Укажите ID заявки на закупку."]}, status=status.HTTP_400_BAD_REQUEST, ) try: procurement_request = ( ProcurementRequest.objects.select_related( "department", "processing_department", "requestor", "executor", ) .prefetch_related("approvals", "items") .get(id=procurement_request_id) ) except ProcurementRequest.DoesNotExist: return Response( {"procurement_request_id": ["Заявка на закупку не найдена."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_procurement_request(request.user, procurement_request): return Response( {"detail": "Нет доступа к этой заявке на закупку."}, status=status.HTTP_403_FORBIDDEN, ) queryset = ( self.get_queryset() .filter( linked_objects__kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, linked_objects__content_type=get_procurement_request_content_type(), linked_objects__object_id=procurement_request.id, ) .distinct() ) 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) @action(detail=False, methods=["get"], url_path="linked-post-tasks") def linked_post_tasks(self, request): post_id = request.query_params.get("post_id") try: post_id = int(post_id) except (TypeError, ValueError): return Response( {"post_id": ["Укажите ID новости."]}, status=status.HTTP_400_BAD_REQUEST, ) try: post = Post.objects.select_related("author", "department").get( id=post_id, ) except Post.DoesNotExist: return Response( {"post_id": ["Новость не найдена."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_feed_post(request.user, post): return Response( {"detail": "Нет доступа к этой новости."}, status=status.HTTP_403_FORBIDDEN, ) queryset = ( self.get_queryset() .filter( linked_objects__kind=TaskLinkedObjectKind.POST, linked_objects__content_type=get_post_content_type(), linked_objects__object_id=post.id, ) .distinct() ) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @action(detail=False, methods=["get"], url_path="linked-guest-tasks") def linked_guest_tasks(self, request): guest_id = request.query_params.get("guest_id") try: guest_id = int(guest_id) except (TypeError, ValueError): return Response( {"guest_id": ["Укажите ID гостя."]}, status=status.HTTP_400_BAD_REQUEST, ) try: guest = Guest.objects.get(id=guest_id) except Guest.DoesNotExist: return Response( {"guest_id": ["Гость не найден."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_guest(request.user, guest): return Response( {"detail": "Нет доступа к этому гостю."}, status=status.HTTP_403_FORBIDDEN, ) queryset = ( self.get_queryset() .filter( linked_objects__kind=TaskLinkedObjectKind.GUEST, linked_objects__content_type=get_guest_content_type(), linked_objects__object_id=guest.id, ) .distinct() ) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @action(detail=False, methods=["get"], url_path="linked-guest-visit-tasks") def linked_guest_visit_tasks(self, request): guest_visit_id = request.query_params.get("guest_visit_id") try: guest_visit_id = int(guest_visit_id) except (TypeError, ValueError): return Response( {"guest_visit_id": ["Укажите ID заявки на гостевой визит."]}, status=status.HTTP_400_BAD_REQUEST, ) try: visit = GuestVisit.objects.select_related("guest", "inviter").get( id=guest_visit_id, ) except GuestVisit.DoesNotExist: return Response( {"guest_visit_id": ["Заявка на гостевой визит не найдена."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_guest_visit(request.user, visit): return Response( {"detail": "Нет доступа к этой заявке на гостевой визит."}, status=status.HTTP_403_FORBIDDEN, ) queryset = ( self.get_queryset() .filter( linked_objects__kind=TaskLinkedObjectKind.GUEST_VISIT, linked_objects__content_type=get_guest_visit_content_type(), linked_objects__object_id=visit.id, ) .distinct() ) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @action(detail=False, methods=["get"], url_path="linked-attendance-record-tasks") def linked_attendance_record_tasks(self, request): attendance_record_id = request.query_params.get("attendance_record_id") try: attendance_record_id = int(attendance_record_id) except (TypeError, ValueError): return Response( {"attendance_record_id": ["Укажите ID записи посещаемости."]}, status=status.HTTP_400_BAD_REQUEST, ) try: record = AttendanceRecord.objects.select_related("employee").get( id=attendance_record_id, ) except AttendanceRecord.DoesNotExist: return Response( {"attendance_record_id": ["Запись посещаемости не найдена."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_attendance_record(request.user, record): return Response( {"detail": "Нет доступа к этой записи посещаемости."}, status=status.HTTP_403_FORBIDDEN, ) queryset = ( self.get_queryset() .filter( linked_objects__kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, linked_objects__content_type=get_attendance_record_content_type(), linked_objects__object_id=record.id, ) .distinct() ) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) def _message_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( task=task, kind=TaskLinkedObjectKind.MESSAGE, ) .select_related("created_by", "content_type") .order_by("-created_at", "-id") ) def _event_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( task=task, kind=TaskLinkedObjectKind.CALENDAR_EVENT, ) .select_related("created_by", "content_type") .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") ) def _procurement_request_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( task=task, kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, ) .select_related("created_by", "content_type") .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") ) def _guest_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( task=task, kind=TaskLinkedObjectKind.GUEST, ) .select_related("created_by", "content_type") .order_by("-created_at", "-id") ) def _guest_visit_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( task=task, kind=TaskLinkedObjectKind.GUEST_VISIT, ) .select_related("created_by", "content_type") .order_by("-created_at", "-id") ) def _attendance_record_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( task=task, kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, ) .select_related("created_by", "content_type") .order_by("-created_at", "-id") ) def _post_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( task=task, kind=TaskLinkedObjectKind.POST, ) .select_related("created_by", "content_type") .order_by("-created_at", "-id") ) @action(detail=True, methods=["get", "post"], url_path="linked-posts") def linked_posts(self, request, pk=None): task = self.get_object() if request.method == "GET": serializer = TaskLinkedPostSerializer( self._post_links_queryset(task), many=True, context={"request": request}, ) return Response(serializer.data) post_id = request.data.get("post_id") try: post_id = int(post_id) except (TypeError, ValueError): return Response( {"post_id": ["Укажите ID новости."]}, status=status.HTTP_400_BAD_REQUEST, ) try: post = Post.objects.select_related("author", "department").get( id=post_id, ) except Post.DoesNotExist: return Response( {"post_id": ["Новость не найдена."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_feed_post(request.user, post): return Response( {"detail": "Нет доступа к этой новости."}, status=status.HTTP_403_FORBIDDEN, ) link, created = TaskLinkedObject.objects.get_or_create( task=task, kind=TaskLinkedObjectKind.POST, content_type=get_post_content_type(), object_id=post.id, defaults={"created_by": request.user}, ) serializer = TaskLinkedPostSerializer( link, context={"request": request}, ) if created: create_task_activity( task, request.user, TaskActivityAction.LINKED, object_kind=TaskLinkedObjectKind.POST, object_id=post.id, metadata={ "object_label": post.title, "object_type": "Новость", }, ) send_task_board_update( task.board, "linked", "post", post.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-posts/(?P\d+)", ) def unlink_post(self, request, pk=None, link_id=None): task = self.get_object() try: link = self._post_links_queryset(task).get(id=link_id) except TaskLinkedObject.DoesNotExist: return Response( {"detail": "Связь не найдена."}, status=status.HTTP_404_NOT_FOUND, ) post_id = link.object_id link.delete() create_task_activity( task, request.user, TaskActivityAction.UNLINKED, object_kind=TaskLinkedObjectKind.POST, object_id=post_id, metadata={"object_type": "Новость"}, ) send_task_board_update( task.board, "unlinked", "post", post_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-messages") def linked_messages(self, request, pk=None): task = self.get_object() if request.method == "GET": serializer = TaskLinkedMessageSerializer( self._message_links_queryset(task), many=True, context={"request": request}, ) return Response(serializer.data) message_id = request.data.get("message_id") try: message_id = int(message_id) except (TypeError, ValueError): return Response( {"message_id": ["Укажите ID сообщения."]}, status=status.HTTP_400_BAD_REQUEST, ) try: message = ( Message.objects.select_related( "author", "chat", "reply_to", "reply_to__author", "poll", ) .prefetch_related( "attachments", "reactions", "reactions__user", "poll__options", ) .get(id=message_id) ) except Message.DoesNotExist: return Response( {"message_id": ["Сообщение не найдено."]}, status=status.HTTP_404_NOT_FOUND, ) if message.is_deleted: return Response( {"message_id": ["Удаленное сообщение нельзя связать с задачей."]}, status=status.HTTP_400_BAD_REQUEST, ) if not user_can_access_chat(message.chat, request.user): return Response( {"detail": "Нет доступа к этому сообщению."}, status=status.HTTP_403_FORBIDDEN, ) link, created = TaskLinkedObject.objects.get_or_create( task=task, kind=TaskLinkedObjectKind.MESSAGE, content_type=get_message_content_type(), object_id=message.id, defaults={"created_by": request.user}, ) serializer = TaskLinkedMessageSerializer( link, context={"request": request}, ) if created: create_task_activity( task, request.user, TaskActivityAction.LINKED, object_kind=TaskLinkedObjectKind.MESSAGE, object_id=message.id, metadata={ "object_label": message.content[:120], "object_type": "Сообщение", }, ) send_task_board_update( task.board, "linked", "message", message.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-messages/(?P\d+)", ) def unlink_message(self, request, pk=None, link_id=None): task = self.get_object() try: link = self._message_links_queryset(task).get(id=link_id) except TaskLinkedObject.DoesNotExist: return Response( {"detail": "Связь не найдена."}, status=status.HTTP_404_NOT_FOUND, ) message_id = link.object_id link.delete() create_task_activity( task, request.user, TaskActivityAction.UNLINKED, object_kind=TaskLinkedObjectKind.MESSAGE, object_id=message_id, metadata={"object_type": "Сообщение"}, ) send_task_board_update( task.board, "unlinked", "message", message_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-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-procurement-requests" ) def linked_procurement_requests(self, request, pk=None): task = self.get_object() if request.method == "GET": serializer = TaskLinkedProcurementRequestSerializer( self._procurement_request_links_queryset(task), many=True, context={"request": request}, ) return Response(serializer.data) procurement_request_id = request.data.get("procurement_request_id") try: procurement_request_id = int(procurement_request_id) except (TypeError, ValueError): return Response( {"procurement_request_id": ["Укажите ID заявки на закупку."]}, status=status.HTTP_400_BAD_REQUEST, ) try: procurement_request = ( ProcurementRequest.objects.select_related( "department", "processing_department", "requestor", "executor", ) .prefetch_related("approvals", "items") .get(id=procurement_request_id) ) except ProcurementRequest.DoesNotExist: return Response( {"procurement_request_id": ["Заявка на закупку не найдена."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_procurement_request(request.user, procurement_request): return Response( {"detail": "Нет доступа к этой заявке на закупку."}, status=status.HTTP_403_FORBIDDEN, ) link, created = TaskLinkedObject.objects.get_or_create( task=task, kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, content_type=get_procurement_request_content_type(), object_id=procurement_request.id, defaults={"created_by": request.user}, ) serializer = TaskLinkedProcurementRequestSerializer( link, context={"request": request}, ) if created: create_task_activity( task, request.user, TaskActivityAction.LINKED, object_kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, object_id=procurement_request.id, metadata={ "object_label": procurement_request.title, "object_type": "Заявка на закупку", }, ) send_task_board_update( task.board, "linked", "procurement_request", procurement_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-procurement-requests/(?P\d+)", ) def unlink_procurement_request(self, request, pk=None, link_id=None): task = self.get_object() try: link = self._procurement_request_links_queryset(task).get(id=link_id) except TaskLinkedObject.DoesNotExist: return Response( {"detail": "Связь не найдена."}, status=status.HTTP_404_NOT_FOUND, ) procurement_request_id = link.object_id link.delete() create_task_activity( task, request.user, TaskActivityAction.UNLINKED, object_kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, object_id=procurement_request_id, metadata={"object_type": "Заявка на закупку"}, ) send_task_board_update( task.board, "unlinked", "procurement_request", procurement_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-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-guests") def linked_guests(self, request, pk=None): task = self.get_object() if request.method == "GET": serializer = TaskLinkedGuestSerializer( self._guest_links_queryset(task), many=True, context={"request": request}, ) return Response(serializer.data) guest_id = request.data.get("guest_id") try: guest_id = int(guest_id) except (TypeError, ValueError): return Response( {"guest_id": ["Укажите ID гостя."]}, status=status.HTTP_400_BAD_REQUEST, ) try: guest = Guest.objects.get(id=guest_id) except Guest.DoesNotExist: return Response( {"guest_id": ["Гость не найден."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_guest(request.user, guest): return Response( {"detail": "Нет доступа к этому гостю."}, status=status.HTTP_403_FORBIDDEN, ) link, created = TaskLinkedObject.objects.get_or_create( task=task, kind=TaskLinkedObjectKind.GUEST, content_type=get_guest_content_type(), object_id=guest.id, defaults={"created_by": request.user}, ) serializer = TaskLinkedGuestSerializer( link, context={"request": request}, ) if created: create_task_activity( task, request.user, TaskActivityAction.LINKED, object_kind=TaskLinkedObjectKind.GUEST, object_id=guest.id, metadata={ "object_label": guest.full_name, "object_type": "Гость", }, ) send_task_board_update( task.board, "linked", "guest", guest.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-guests/(?P\d+)", ) def unlink_guest(self, request, pk=None, link_id=None): task = self.get_object() try: link = self._guest_links_queryset(task).get(id=link_id) except TaskLinkedObject.DoesNotExist: return Response( {"detail": "Связь не найдена."}, status=status.HTTP_404_NOT_FOUND, ) guest_id = link.object_id link.delete() create_task_activity( task, request.user, TaskActivityAction.UNLINKED, object_kind=TaskLinkedObjectKind.GUEST, object_id=guest_id, metadata={"object_type": "Гость"}, ) send_task_board_update( task.board, "unlinked", "guest", guest_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-guest-visits") def linked_guest_visits(self, request, pk=None): task = self.get_object() if request.method == "GET": serializer = TaskLinkedGuestVisitSerializer( self._guest_visit_links_queryset(task), many=True, context={"request": request}, ) return Response(serializer.data) guest_visit_id = request.data.get("guest_visit_id") try: guest_visit_id = int(guest_visit_id) except (TypeError, ValueError): return Response( {"guest_visit_id": ["Укажите ID заявки на гостевой визит."]}, status=status.HTTP_400_BAD_REQUEST, ) try: visit = GuestVisit.objects.select_related("guest", "inviter").get( id=guest_visit_id, ) except GuestVisit.DoesNotExist: return Response( {"guest_visit_id": ["Заявка на гостевой визит не найдена."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_guest_visit(request.user, visit): return Response( {"detail": "Нет доступа к этой заявке на гостевой визит."}, status=status.HTTP_403_FORBIDDEN, ) link, created = TaskLinkedObject.objects.get_or_create( task=task, kind=TaskLinkedObjectKind.GUEST_VISIT, content_type=get_guest_visit_content_type(), object_id=visit.id, defaults={"created_by": request.user}, ) serializer = TaskLinkedGuestVisitSerializer( link, context={"request": request}, ) if created: create_task_activity( task, request.user, TaskActivityAction.LINKED, object_kind=TaskLinkedObjectKind.GUEST_VISIT, object_id=visit.id, metadata={ "object_label": visit.guest.full_name, "object_type": "Заявка на гостевой визит", }, ) send_task_board_update( task.board, "linked", "guest_visit", visit.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-guest-visits/(?P\d+)", ) def unlink_guest_visit(self, request, pk=None, link_id=None): task = self.get_object() try: link = self._guest_visit_links_queryset(task).get(id=link_id) except TaskLinkedObject.DoesNotExist: return Response( {"detail": "Связь не найдена."}, status=status.HTTP_404_NOT_FOUND, ) guest_visit_id = link.object_id link.delete() create_task_activity( task, request.user, TaskActivityAction.UNLINKED, object_kind=TaskLinkedObjectKind.GUEST_VISIT, object_id=guest_visit_id, metadata={"object_type": "Заявка на гостевой визит"}, ) send_task_board_update( task.board, "unlinked", "guest_visit", guest_visit_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-attendance-records") def linked_attendance_records(self, request, pk=None): task = self.get_object() if request.method == "GET": serializer = TaskLinkedAttendanceRecordSerializer( self._attendance_record_links_queryset(task), many=True, context={"request": request}, ) return Response(serializer.data) attendance_record_id = request.data.get("attendance_record_id") try: attendance_record_id = int(attendance_record_id) except (TypeError, ValueError): return Response( {"attendance_record_id": ["Укажите ID записи посещаемости."]}, status=status.HTTP_400_BAD_REQUEST, ) try: record = AttendanceRecord.objects.select_related("employee").get( id=attendance_record_id, ) except AttendanceRecord.DoesNotExist: return Response( {"attendance_record_id": ["Запись посещаемости не найдена."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_attendance_record(request.user, record): return Response( {"detail": "Нет доступа к этой записи посещаемости."}, status=status.HTTP_403_FORBIDDEN, ) link, created = TaskLinkedObject.objects.get_or_create( task=task, kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, content_type=get_attendance_record_content_type(), object_id=record.id, defaults={"created_by": request.user}, ) serializer = TaskLinkedAttendanceRecordSerializer( link, context={"request": request}, ) if created: employee_name = str(record.employee) if record.employee_id else "" create_task_activity( task, request.user, TaskActivityAction.LINKED, object_kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, object_id=record.id, metadata={ "object_label": f"{employee_name} {record.date}".strip(), "object_type": "Запись посещаемости", }, ) send_task_board_update( task.board, "linked", "attendance_record", record.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-attendance-records/(?P\d+)", ) def unlink_attendance_record(self, request, pk=None, link_id=None): task = self.get_object() try: link = self._attendance_record_links_queryset(task).get(id=link_id) except TaskLinkedObject.DoesNotExist: return Response( {"detail": "Связь не найдена."}, status=status.HTTP_404_NOT_FOUND, ) attendance_record_id = link.object_id link.delete() create_task_activity( task, request.user, TaskActivityAction.UNLINKED, object_kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, object_id=attendance_record_id, metadata={"object_type": "Запись посещаемости"}, ) send_task_board_update( task.board, "unlinked", "attendance_record", attendance_record_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() if request.method == "GET": serializer = TaskLinkedCalendarEventSerializer( self._event_links_queryset(task), many=True, context={"request": request}, ) return Response(serializer.data) event_id = request.data.get("event_id") try: event_id = int(event_id) except (TypeError, ValueError): return Response( {"event_id": ["Укажите ID события."]}, status=status.HTTP_400_BAD_REQUEST, ) try: event = Event.objects.select_related( "calendar", "calendar__binding", "calendar__binding__context_content_type", "rule", "creator", ).get(id=event_id) except Event.DoesNotExist: return Response( {"event_id": ["Событие не найдено."]}, status=status.HTTP_404_NOT_FOUND, ) if not user_can_access_calendar_event(request.user, event): return Response( {"detail": "Нет доступа к этому событию."}, status=status.HTTP_403_FORBIDDEN, ) link, created = TaskLinkedObject.objects.get_or_create( task=task, kind=TaskLinkedObjectKind.CALENDAR_EVENT, content_type=get_calendar_event_content_type(), object_id=event.id, defaults={"created_by": request.user}, ) serializer = TaskLinkedCalendarEventSerializer( link, context={"request": request}, ) if created: create_task_activity( task, request.user, TaskActivityAction.LINKED, object_kind=TaskLinkedObjectKind.CALENDAR_EVENT, object_id=event.id, metadata={ "object_label": event.title, "object_type": "Календарное событие", }, ) send_task_board_update( task.board, "linked", "calendar_event", event.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-events/(?P\d+)", ) def unlink_event(self, request, pk=None, link_id=None): task = self.get_object() try: link = self._event_links_queryset(task).get(id=link_id) except TaskLinkedObject.DoesNotExist: return Response( {"detail": "Связь не найдена."}, status=status.HTTP_404_NOT_FOUND, ) event_id = link.object_id link.delete() create_task_activity( task, request.user, TaskActivityAction.UNLINKED, object_kind=TaskLinkedObjectKind.CALENDAR_EVENT, object_id=event_id, metadata={"object_type": "Календарное событие"}, ) send_task_board_update( task.board, "unlinked", "calendar_event", event_id, extra={"task_id": task.id, "link_id": int(link_id)}, ) return Response(status=status.HTTP_204_NO_CONTENT)