from django.contrib.auth import get_user_model from django.db import transaction from django.db.models import ( Max, ) from rest_framework import filters, status, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from tasks.activity import create_task_activity from tasks.models import ( Task, TaskActivityAction, TaskColumn, TaskColumnRow, ) from tasks.notifications import ( notify_task_created, notify_task_deleted, notify_task_moved, notify_task_updated, ) from tasks.positioning import normalize_column_positions from tasks.queries import task_api_queryset from tasks.realtime import send_task_board_update from ..serializers import ( TaskActivitySerializer, TaskSerializer, ) from .mixins.checklist import TaskChecklistAndLinksMixin from .mixins.comments import TaskCommentsMixin from .mixins.linked_queries import TaskLinkedTaskQueriesMixin from .mixins.linked_resources import TaskLinkedResourcesMixin from .mixins.media import TaskMediaMixin Employee = get_user_model() class TaskViewSet( TaskLinkedTaskQueriesMixin, TaskLinkedResourcesMixin, TaskMediaMixin, TaskChecklistAndLinksMixin, TaskCommentsMixin, viewsets.ModelViewSet, ): queryset = Task.objects.none() 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): return task_api_queryset( user=self.request.user, params=self.request.query_params, ) 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)), "skill_ids": sorted(instance.skills.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)), "skill_ids": sorted(task.skills.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 normalize_positions = position not in (None, "") 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._skill_completion_actor = actor task.save( update_fields=["column", "row", "position", "completed_at", "updated_at"] ) # При обычном переносе карточка добавляется в конец с разреженной # позицией. Перенумерация обеих колонок здесь не нужна и на больших # досках превращалась в сотни отдельных UPDATE. Нормализуем порядок # только когда клиент явно передал позицию для сортировки внутри ячейки. if normalize_positions: 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, "position": task.position, }, ) 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, ) previous_assignee_id = task.assignee_id if task.assignee_id and task.assignee.is_active: 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": previous_assignee_id}, {"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)