""" 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=&student= """ 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 )