uchill/backend/apps/board/views.py

506 lines
20 KiB
Python
Raw Permalink 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.

"""
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
)