352 lines
13 KiB
Python
352 lines
13 KiB
Python
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)),
|
||
"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
|
||
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)
|