Skip to content

Commit 2afa7f6

Browse files
committed
Реализовано API для колонок с перемещением, добавлен WS consumer
1 parent 9613af9 commit 2afa7f6

6 files changed

Lines changed: 168 additions & 27 deletions

File tree

kanban/consumers.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from channels.generic.websocket import AsyncJsonWebsocketConsumer
2+
3+
4+
class KanbanConsumer(AsyncJsonWebsocketConsumer):
5+
async def connect(self):
6+
user = self.scope.get("user")
7+
if not user or user.is_anonymous:
8+
return await self.close(403)
9+
10+
# Подписываем на все проекты, где пользователь лидер или коллаборатор
11+
project_ids = set()
12+
project_ids.update(user.leaders_projects.values_list("id", flat=True))
13+
collaborator_projects = user.collaborations.values_list("project_id", flat=True)
14+
project_ids.update(collaborator_projects)
15+
16+
for project_id in project_ids:
17+
await self.channel_layer.group_add(f"kanban_{project_id}", self.channel_name)
18+
19+
await self.accept(subprotocol=self.scope.get("subprotocols", [None])[0])
20+
21+
async def disconnect(self, close_code):
22+
user = self.scope.get("user")
23+
if not user or user.is_anonymous:
24+
return
25+
project_ids = set()
26+
project_ids.update(user.leaders_projects.values_list("id", flat=True))
27+
project_ids.update(user.collaborations.values_list("project_id", flat=True))
28+
for project_id in project_ids:
29+
await self.channel_layer.group_discard(
30+
f"kanban_{project_id}", self.channel_name
31+
)
32+
33+
async def kanban_event(self, event):
34+
payload = event.get("payload", {})
35+
await self.send_json(payload)

kanban/routing.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.urls import path
2+
3+
from kanban.consumers import KanbanConsumer
4+
5+
websocket_urlpatterns = [
6+
path("ws/kanban/", KanbanConsumer.as_asgi()),
7+
]

kanban/serializers.py

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,37 +48,30 @@ def create(self, validated_data):
4848

4949

5050
class BoardColumnSerializer(serializers.ModelSerializer):
51-
board = serializers.PrimaryKeyRelatedField(queryset=Board.objects.all())
51+
board = serializers.PrimaryKeyRelatedField(
52+
queryset=Board.objects.all(), write_only=True
53+
)
54+
board_id = serializers.IntegerField(source="board.id", read_only=True)
5255
tasks_count = serializers.IntegerField(read_only=True)
56+
order = serializers.IntegerField(read_only=True)
5357

5458
class Meta:
5559
model = BoardColumn
5660
fields = (
5761
"id",
5862
"board",
63+
"board_id",
5964
"name",
6065
"order",
6166
"tasks_count",
6267
"created_at",
6368
"updated_at",
6469
)
65-
read_only_fields = ("id", "tasks_count", "created_at", "updated_at")
66-
67-
def validate_board(self, board: Board):
68-
request = self.context.get("request")
69-
user = getattr(request, "user", None)
70-
if not user or not user.is_authenticated:
71-
raise serializers.ValidationError("Требуется аутентификация")
72-
73-
if board.project.leader_id == user.id:
74-
return board
75-
76-
is_member = Collaborator.objects.filter(
77-
project_id=board.project_id,
78-
user_id=user.id,
79-
).exists()
80-
if not is_member:
81-
raise serializers.ValidationError(
82-
"Пользователь не является участником проекта"
83-
)
84-
return board
70+
read_only_fields = (
71+
"id",
72+
"tasks_count",
73+
"order",
74+
"created_at",
75+
"updated_at",
76+
"board_id",
77+
)

kanban/tests/test_column_api.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,16 @@ def test_list_columns_returns_board_columns(self):
1919
response = self.client.get(self.url)
2020
self.assertEqual(response.status_code, status.HTTP_200_OK)
2121
self.assertGreaterEqual(len(response.data), 1)
22-
self.assertEqual(response.data[0]["board"], self.board.id)
22+
self.assertEqual(response.data[0]["board_id"], self.board.id)
2323

2424
def test_create_column(self):
25-
payload = {"board": self.board.id, "name": "In Progress", "order": 5}
25+
payload = {"board": self.board.id, "name": "In Progress"}
2626
response = self.client.post(self.url, payload, format="json")
2727
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
2828
column_id = response.data["id"]
2929
column = BoardColumn.objects.get(id=column_id)
3030
self.assertEqual(column.name, "In Progress")
31+
self.assertEqual(column.order, self.board.columns.count())
3132

3233
def test_cannot_create_column_in_foreign_project(self):
3334
foreign_leader = CustomUser.objects.create(
@@ -41,7 +42,7 @@ def test_cannot_create_column_in_foreign_project(self):
4142
foreign_board = Board.objects.create(
4243
project=foreign_project, name="Foreign Board"
4344
)
44-
payload = {"board": foreign_board.id, "name": "Forbidden", "order": 1}
45+
payload = {"board": foreign_board.id, "name": "Forbidden"}
4546
response = self.client.post(self.url, payload, format="json")
4647
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
4748

kanban/views.py

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
from django.db.models import Q
1+
from django.db.models import Q, Max
2+
from drf_yasg import openapi
23
from drf_yasg.utils import swagger_auto_schema
3-
from rest_framework import permissions, viewsets
4+
from rest_framework import permissions, status, viewsets
5+
from rest_framework.decorators import action
6+
from rest_framework.exceptions import ValidationError
47
from rest_framework.response import Response
5-
from rest_framework import status
68

79
from kanban.models import Board, BoardColumn
810
from kanban.serializers import BoardSerializer, BoardColumnSerializer
@@ -15,6 +17,8 @@ class BoardViewSet(viewsets.ModelViewSet):
1517

1618
def get_queryset(self):
1719
user = self.request.user
20+
if not user.is_authenticated:
21+
return Board.objects.none()
1822
collaborator_projects = Collaborator.objects.filter(user=user).values(
1923
"project_id"
2024
)
@@ -55,13 +59,23 @@ def update(self, request, *args, **kwargs):
5559
def destroy(self, request, *args, **kwargs):
5660
return super().destroy(request, *args, **kwargs)
5761

62+
@swagger_auto_schema(tags=["Kanban Boards"])
63+
@action(detail=True, methods=["get"], url_path="columns")
64+
def columns(self, request, pk=None):
65+
board = self.get_object()
66+
columns = board.columns.order_by("order", "id")
67+
serializer = BoardColumnSerializer(columns, many=True)
68+
return Response(serializer.data)
69+
5870

5971
class BoardColumnViewSet(viewsets.ModelViewSet):
6072
serializer_class = BoardColumnSerializer
6173
permission_classes = [permissions.IsAuthenticated]
6274

6375
def get_queryset(self):
6476
user = self.request.user
77+
if not user.is_authenticated:
78+
return BoardColumn.objects.none()
6579
collaborator_projects = Collaborator.objects.filter(user=user).values(
6680
"project_id"
6781
)
@@ -104,3 +118,92 @@ def destroy(self, request, *args, **kwargs):
104118
if isinstance(exc, ValidationError):
105119
return Response(exc.messages, status=status.HTTP_400_BAD_REQUEST)
106120
raise
121+
122+
def perform_create(self, serializer):
123+
board = serializer.validated_data.get("board")
124+
if not board:
125+
raise ValidationError("Укажите board")
126+
127+
user = self.request.user
128+
collaborator_projects = set(
129+
Collaborator.objects.filter(user=user).values_list("project_id", flat=True)
130+
)
131+
if not (
132+
board.project.leader_id == user.id
133+
or board.project_id in collaborator_projects
134+
):
135+
raise ValidationError("Пользователь не является участником проекта")
136+
137+
next_order = (
138+
BoardColumn.objects.filter(board=board).aggregate(Max("order"))["order__max"]
139+
or 0
140+
)
141+
serializer.save(board=board, order=next_order + 1)
142+
143+
@swagger_auto_schema(
144+
methods=["post"],
145+
tags=["Kanban Columns"],
146+
operation_summary="Переместить колонку",
147+
operation_description="Изменяет порядок колонки внутри доски.",
148+
request_body=openapi.Schema(
149+
type=openapi.TYPE_OBJECT,
150+
properties={"new_order": openapi.Schema(type=openapi.TYPE_INTEGER)},
151+
required=["new_order"],
152+
),
153+
)
154+
@action(detail=True, methods=["post"], url_path="move")
155+
def move(self, request, pk=None):
156+
from django.db import transaction
157+
from channels.layers import get_channel_layer
158+
from asgiref.sync import async_to_sync
159+
from kanban.models import BoardColumn
160+
161+
try:
162+
new_order = int(request.data.get("new_order"))
163+
except (TypeError, ValueError):
164+
return Response(
165+
{"detail": "new_order должен быть числом"},
166+
status=status.HTTP_400_BAD_REQUEST,
167+
)
168+
169+
column = (
170+
BoardColumn.objects.select_related("board", "board__project")
171+
.select_for_update()
172+
.get(pk=pk)
173+
)
174+
175+
with transaction.atomic():
176+
columns = list(
177+
BoardColumn.objects.filter(board=column.board)
178+
.select_for_update()
179+
.order_by("order", "id")
180+
)
181+
columns = [c for c in columns if c.id != column.id]
182+
insert_index = max(0, min(new_order - 1, len(columns)))
183+
columns.insert(insert_index, column)
184+
185+
# Шаг 1: временно сдвигаем порядки, чтобы не ловить UNIQUE при массовом обновлении
186+
for idx, col in enumerate(columns, start=1):
187+
col.order = idx + 1000
188+
BoardColumn.objects.bulk_update(columns, ["order"])
189+
190+
# Шаг 2: выставляем финальные порядки
191+
for idx, col in enumerate(columns, start=1):
192+
col.order = idx
193+
194+
BoardColumn.objects.bulk_update(columns, ["order"])
195+
196+
channel_layer = get_channel_layer()
197+
payload = {
198+
"action": "column.reordered",
199+
"board_id": column.board_id,
200+
"project_id": column.board.project_id,
201+
"columns": [{"id": c.id, "order": c.order, "name": c.name} for c in columns],
202+
}
203+
async_to_sync(channel_layer.group_send)(
204+
f"kanban_{column.board.project_id}",
205+
{"type": "kanban.event", "payload": payload},
206+
)
207+
208+
serializer = self.get_serializer(columns, many=True)
209+
return Response(serializer.data, status=status.HTTP_200_OK)

procollab/websocket_routing.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from chats.routing import websocket_urlpatterns as chat_websocket_urlpatterns
2+
from kanban.routing import websocket_urlpatterns as kanban_websocket_urlpatterns
23

34
websocket_urlpatterns = []
45
websocket_urlpatterns += chat_websocket_urlpatterns
6+
websocket_urlpatterns += kanban_websocket_urlpatterns

0 commit comments

Comments
 (0)