2026-08-25 14:43:34 +03:00

354 lines
13 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.

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 .mixins.checklist import TaskChecklistAndLinksMixin
from .mixins.comments import TaskCommentsMixin
from .mixins.linked_resources import TaskLinkedResourcesMixin
from .mixins.linked_queries import TaskLinkedTaskQueriesMixin
from .mixins.media import TaskMediaMixin
from ..serializers import (
TaskActivitySerializer,
TaskSerializer,
)
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)