Corp/backend/api/v1/tasks/views.py

3898 lines
139 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<attachment_id>\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<attachment_id>\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<attachment_id>\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<item_id>\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<link_id>\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<comment_id>\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<link_id>\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<link_id>\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<link_id>\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<link_id>\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<link_id>\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<link_id>\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<link_id>\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<link_id>\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<link_id>\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<link_id>\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)