506 lines
20 KiB
Python
506 lines
20 KiB
Python
"""
|
||
API views для интерактивной доски.
|
||
"""
|
||
from rest_framework import viewsets, status
|
||
from rest_framework.decorators import action
|
||
from rest_framework.response import Response
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from django.shortcuts import get_object_or_404
|
||
from django.db import models
|
||
from .models import Board, BoardElement, BoardSnapshot
|
||
from .serializers import (
|
||
BoardSerializer,
|
||
BoardListSerializer,
|
||
BoardCreateSerializer,
|
||
BoardElementSerializer,
|
||
BoardElementCreateSerializer,
|
||
BoardSnapshotSerializer,
|
||
BoardSnapshotCreateSerializer
|
||
)
|
||
from .permissions import IsBoardOwnerOrParticipant, IsBoardOwner
|
||
|
||
|
||
class BoardViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
ViewSet для управления досками.
|
||
|
||
list: Список досок пользователя
|
||
create: Создать доску
|
||
retrieve: Получить доску с элементами
|
||
update: Обновить доску
|
||
destroy: Удалить доску
|
||
join: Получить информацию для присоединения
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
lookup_field = 'board_id'
|
||
|
||
def get_queryset(self):
|
||
"""Получение досок пользователя."""
|
||
user = self.request.user
|
||
|
||
# Фильтры из query params
|
||
mentor_id = self.request.query_params.get('mentor')
|
||
student_id = self.request.query_params.get('student')
|
||
|
||
# Базовый queryset - доски где пользователь владелец, участник, ментор или студент
|
||
queryset = Board.objects.filter(
|
||
models.Q(owner=user) |
|
||
models.Q(participants=user) |
|
||
models.Q(mentor=user) |
|
||
models.Q(student=user) |
|
||
models.Q(access_type='public')
|
||
).distinct()
|
||
|
||
# Фильтр по паре ментор-студент (для получения персональной доски)
|
||
if mentor_id and student_id:
|
||
queryset = queryset.filter(mentor_id=mentor_id, student_id=student_id)
|
||
|
||
# Фильтр по занятию удален - доска привязана к паре mentor-student, а не к уроку
|
||
|
||
queryset = queryset.select_related(
|
||
'owner',
|
||
'mentor',
|
||
'student',
|
||
'last_edited_by'
|
||
).prefetch_related('participants')
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'board_id', 'title', 'description', 'owner_id', 'mentor_id',
|
||
'student_id', 'access_type', 'is_template', 'is_active',
|
||
'views_count', 'last_edited_by_id', 'last_edited_at',
|
||
'created_at', 'updated_at'
|
||
)
|
||
|
||
return queryset
|
||
|
||
def get_serializer_class(self):
|
||
"""Выбор сериализатора."""
|
||
if self.action == 'list':
|
||
return BoardListSerializer
|
||
elif self.action == 'create':
|
||
return BoardCreateSerializer
|
||
return BoardSerializer
|
||
|
||
def create(self, request, *args, **kwargs):
|
||
"""Создание доски."""
|
||
serializer = self.get_serializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
board = serializer.save()
|
||
|
||
# Возвращаем полную информацию
|
||
response_serializer = BoardSerializer(board)
|
||
return Response(
|
||
response_serializer.data,
|
||
status=status.HTTP_201_CREATED
|
||
)
|
||
|
||
def retrieve(self, request, *args, **kwargs):
|
||
"""Получить доску."""
|
||
board = self.get_object()
|
||
|
||
# Проверяем доступ
|
||
if not board.has_access(request.user):
|
||
return Response(
|
||
{'error': 'У вас нет доступа к этой доске'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Увеличиваем счетчик просмотров
|
||
board.increment_views()
|
||
|
||
serializer = self.get_serializer(board)
|
||
return Response(serializer.data)
|
||
|
||
@action(detail=True, methods=['get'])
|
||
def join(self, request, board_id=None):
|
||
"""
|
||
Получить информацию для присоединения к доске.
|
||
|
||
GET /api/board/boards/{board_id}/join/
|
||
"""
|
||
board = self.get_object()
|
||
|
||
# Проверяем доступ
|
||
if not board.has_access(request.user):
|
||
return Response(
|
||
{'error': 'У вас нет доступа к этой доске'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# WebSocket URL
|
||
ws_url = f"ws://{request.get_host()}/ws/board/{board.board_id}/"
|
||
if request.is_secure():
|
||
ws_url = f"wss://{request.get_host()}/ws/board/{board.board_id}/"
|
||
|
||
# Получаем элементы доски
|
||
elements = board.elements.filter(is_deleted=False).order_by('z_index', 'created_at')
|
||
elements_data = BoardElementSerializer(elements, many=True).data
|
||
|
||
return Response({
|
||
'board_id': str(board.board_id),
|
||
'ws_url': ws_url,
|
||
'board': BoardSerializer(board).data,
|
||
'elements': elements_data
|
||
})
|
||
|
||
@action(detail=False, methods=['get', 'post'], url_path='get-or-create-mentor-student')
|
||
def get_or_create_mentor_student(self, request):
|
||
"""
|
||
Одна доска на пару ментор–студент (вне зависимости от урока).
|
||
Атомарно вернуть существующую или создать новую.
|
||
|
||
GET/POST /api/board/boards/get-or-create-mentor-student/?mentor=<id>&student=<id>
|
||
"""
|
||
from apps.users.models import User
|
||
|
||
mentor_id = request.query_params.get('mentor') or (request.data.get('mentor') if request.data else None)
|
||
student_id = request.query_params.get('student') or (request.data.get('student') if request.data else None)
|
||
|
||
try:
|
||
mentor_id = int(mentor_id) if mentor_id is not None else None
|
||
student_id = int(student_id) if student_id is not None else None
|
||
except (TypeError, ValueError):
|
||
return Response(
|
||
{'error': 'Укажите mentor и student (числовые id)'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
if not mentor_id or not student_id:
|
||
return Response(
|
||
{'error': 'Укажите mentor и student'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
user = request.user
|
||
if user.id not in (mentor_id, student_id):
|
||
return Response(
|
||
{'error': 'Доступ только для ментора или студента этой пары'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
mentor = get_object_or_404(User, id=mentor_id)
|
||
student = get_object_or_404(User, id=student_id)
|
||
|
||
board, created = Board.objects.get_or_create(
|
||
mentor_id=mentor_id,
|
||
student_id=student_id,
|
||
defaults={
|
||
'title': 'Доска для совместной работы',
|
||
'description': 'Интерактивная доска для занятия',
|
||
'access_type': 'mentor_student',
|
||
'owner': mentor,
|
||
}
|
||
)
|
||
if created:
|
||
board.participants.add(mentor, student)
|
||
|
||
serializer = BoardSerializer(board)
|
||
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def my_boards(self, request):
|
||
"""
|
||
Получить доски пользователя.
|
||
|
||
GET /api/board/boards/my_boards/
|
||
"""
|
||
boards = Board.objects.filter(owner=request.user, is_active=True)
|
||
serializer = BoardListSerializer(boards, many=True)
|
||
return Response(serializer.data)
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def shared_with_me(self, request):
|
||
"""
|
||
Получить доски, к которым пользователь имеет доступ.
|
||
|
||
GET /api/board/boards/shared_with_me/
|
||
"""
|
||
boards = Board.objects.filter(
|
||
participants=request.user,
|
||
is_active=True
|
||
).exclude(owner=request.user)
|
||
serializer = BoardListSerializer(boards, many=True)
|
||
return Response(serializer.data)
|
||
|
||
@action(detail=True, methods=['get', 'post'])
|
||
def tldraw(self, request, board_id=None):
|
||
"""
|
||
Получить или сохранить Tldraw состояние.
|
||
|
||
GET /api/board/boards/{board_id}/tldraw/ - получить snapshot
|
||
POST /api/board/boards/{board_id}/tldraw/ - сохранить snapshot
|
||
"""
|
||
board = self.get_object()
|
||
|
||
# Проверяем доступ
|
||
if not board.has_access(request.user):
|
||
return Response(
|
||
{'error': 'У вас нет доступа к этой доске'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
if request.method == 'GET':
|
||
snapshot = board.tldraw_snapshot or {}
|
||
files_count = board.get_files_count()
|
||
elements_count = board.get_elements_count_from_snapshot()
|
||
|
||
return Response({
|
||
'board_id': str(board.board_id),
|
||
'snapshot': snapshot,
|
||
'stats': {
|
||
'files_count': files_count,
|
||
'elements_count': elements_count,
|
||
}
|
||
})
|
||
|
||
elif request.method == 'POST':
|
||
# Сохраняем новое состояние
|
||
snapshot = request.data.get('snapshot', {})
|
||
|
||
# Очищаем неиспользуемые файлы из snapshot
|
||
if isinstance(snapshot, dict):
|
||
elements = snapshot.get('elements', [])
|
||
files = snapshot.get('files', {})
|
||
|
||
if isinstance(files, dict) and isinstance(elements, list):
|
||
# Собираем все используемые fileId
|
||
used_file_ids = set()
|
||
for element in elements:
|
||
if isinstance(element, dict):
|
||
# Проверяем fileId в элементе
|
||
file_id = element.get('fileId')
|
||
if file_id and isinstance(file_id, str):
|
||
used_file_ids.add(file_id)
|
||
|
||
# Проверяем все строковые значения, которые могут быть fileId
|
||
for value in element.values():
|
||
if isinstance(value, str) and value in files:
|
||
used_file_ids.add(value)
|
||
|
||
# Оставляем только используемые файлы
|
||
cleaned_files = {
|
||
file_id: file_data
|
||
for file_id, file_data in files.items()
|
||
if file_id in used_file_ids
|
||
}
|
||
|
||
removed_count = len(files) - len(cleaned_files)
|
||
if removed_count > 0:
|
||
print(f'[BoardViewSet] Очищено {removed_count} неиспользуемых файлов')
|
||
|
||
snapshot['files'] = cleaned_files
|
||
|
||
board.tldraw_snapshot = snapshot
|
||
board.mark_edited(request.user)
|
||
board.save(update_fields=['tldraw_snapshot', 'last_edited_by', 'last_edited_at', 'updated_at'])
|
||
|
||
# Подсчитываем файлы и элементы из snapshot
|
||
files_count = board.get_files_count()
|
||
elements_count = board.get_elements_count_from_snapshot()
|
||
|
||
return Response({
|
||
'success': True,
|
||
'board_id': str(board.board_id),
|
||
'message': 'Состояние доски сохранено',
|
||
'stats': {
|
||
'files_count': files_count,
|
||
'elements_count': elements_count,
|
||
}
|
||
})
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def templates(self, request):
|
||
"""
|
||
Получить шаблоны досок.
|
||
|
||
GET /api/board/boards/templates/
|
||
"""
|
||
boards = Board.objects.filter(is_template=True, is_active=True)
|
||
serializer = BoardListSerializer(boards, many=True)
|
||
return Response(serializer.data)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def duplicate(self, request, board_id=None):
|
||
"""
|
||
Дублировать доску.
|
||
|
||
POST /api/board/boards/{board_id}/duplicate/
|
||
"""
|
||
original_board = self.get_object()
|
||
|
||
# Проверяем доступ
|
||
if not original_board.has_access(request.user):
|
||
return Response(
|
||
{'error': 'У вас нет доступа к этой доске'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Создаем копию доски
|
||
new_board = Board.objects.create(
|
||
title=f"{original_board.title} (копия)",
|
||
description=original_board.description,
|
||
owner=request.user,
|
||
access_type='private',
|
||
background_color=original_board.background_color,
|
||
grid_enabled=original_board.grid_enabled,
|
||
width=original_board.width,
|
||
height=original_board.height
|
||
)
|
||
|
||
# Копируем элементы
|
||
# Оптимизация: используем bulk_create вместо цикла с create()
|
||
elements = list(original_board.elements.filter(is_deleted=False))
|
||
new_elements = []
|
||
for element in elements:
|
||
new_elements.append(
|
||
BoardElement(
|
||
board=new_board,
|
||
created_by=request.user,
|
||
element_type=element.element_type,
|
||
x=element.x,
|
||
y=element.y,
|
||
width=element.width,
|
||
height=element.height,
|
||
rotation=element.rotation,
|
||
z_index=element.z_index,
|
||
content=element.content,
|
||
font_size=element.font_size,
|
||
font_family=element.font_family,
|
||
font_weight=element.font_weight,
|
||
text_align=element.text_align,
|
||
text_color=element.text_color,
|
||
shape_type=element.shape_type,
|
||
fill_color=element.fill_color,
|
||
stroke_color=element.stroke_color,
|
||
stroke_width=element.stroke_width,
|
||
opacity=element.opacity,
|
||
image_url=element.image_url,
|
||
drawing_data=element.drawing_data
|
||
)
|
||
)
|
||
if new_elements:
|
||
BoardElement.objects.bulk_create(new_elements)
|
||
|
||
new_board.update_elements_count()
|
||
|
||
serializer = BoardSerializer(new_board)
|
||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||
|
||
|
||
class BoardElementViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
ViewSet для управления элементами доски.
|
||
|
||
list: Список элементов доски
|
||
create: Создать элемент
|
||
retrieve: Получить элемент
|
||
update: Обновить элемент
|
||
destroy: Удалить элемент
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated, IsBoardOwnerOrParticipant]
|
||
|
||
def get_queryset(self):
|
||
"""Получение элементов."""
|
||
board_id = self.request.query_params.get('board_id')
|
||
|
||
if board_id:
|
||
queryset = BoardElement.objects.filter(
|
||
board__board_id=board_id,
|
||
is_deleted=False
|
||
).select_related('board', 'created_by', 'locked_by')
|
||
else:
|
||
queryset = BoardElement.objects.filter(
|
||
is_deleted=False
|
||
).select_related('board', 'created_by', 'locked_by')
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'board_id', 'element_type', 'element_data', 'position_x', 'position_y',
|
||
'width', 'height', 'z_index', 'created_by_id', 'locked_by_id', 'is_locked',
|
||
'is_deleted', 'created_at', 'updated_at'
|
||
)
|
||
|
||
return queryset
|
||
|
||
def get_serializer_class(self):
|
||
"""Выбор сериализатора."""
|
||
if self.action == 'create':
|
||
return BoardElementCreateSerializer
|
||
return BoardElementSerializer
|
||
|
||
def create(self, request, *args, **kwargs):
|
||
"""Создание элемента."""
|
||
serializer = self.get_serializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
element = serializer.save()
|
||
|
||
response_serializer = BoardElementSerializer(element)
|
||
return Response(
|
||
response_serializer.data,
|
||
status=status.HTTP_201_CREATED
|
||
)
|
||
|
||
def destroy(self, request, *args, **kwargs):
|
||
"""Удаление элемента (мягкое)."""
|
||
element = self.get_object()
|
||
element.soft_delete()
|
||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||
|
||
|
||
class BoardSnapshotViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
ViewSet для управления снимками досок.
|
||
|
||
list: Список снимков доски
|
||
create: Создать снимок
|
||
retrieve: Получить снимок
|
||
destroy: Удалить снимок
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def get_queryset(self):
|
||
"""Получение снимков."""
|
||
board_id = self.request.query_params.get('board_id')
|
||
|
||
if board_id:
|
||
queryset = BoardSnapshot.objects.filter(
|
||
board__board_id=board_id
|
||
).select_related('board', 'created_by')
|
||
else:
|
||
# Снимки досок пользователя
|
||
queryset = BoardSnapshot.objects.filter(
|
||
board__owner=self.request.user
|
||
).select_related('board', 'created_by')
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'board_id', 'snapshot_data', 'created_by_id', 'description', 'created_at'
|
||
)
|
||
|
||
return queryset.order_by('-created_at')
|
||
|
||
def get_serializer_class(self):
|
||
"""Выбор сериализатора."""
|
||
if self.action == 'create':
|
||
return BoardSnapshotCreateSerializer
|
||
return BoardSnapshotSerializer
|
||
|
||
def create(self, request, *args, **kwargs):
|
||
"""Создание снимка."""
|
||
serializer = self.get_serializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
snapshot = serializer.save()
|
||
|
||
response_serializer = BoardSnapshotSerializer(snapshot)
|
||
return Response(
|
||
response_serializer.data,
|
||
status=status.HTTP_201_CREATED
|
||
)
|