""" API views для работы с LiveKit. """ import json import logging import uuid from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from django.conf import settings from .livekit_service import LiveKitService from .models import VideoRoom from apps.board.models import Board from apps.schedule.models import Lesson from apps.users.utils import format_datetime_for_user logger = logging.getLogger(__name__) @api_view(['POST']) @permission_classes([IsAuthenticated]) def create_livekit_room(request): """ Получение данных LiveKit комнаты для занятия. Комната создаётся автоматически при создании урока. POST /api/video/livekit/create-room/ Body: { "lesson_id": 123 } Returns: { "room_name": "uuid", "access_token": "token", "video_room_id": 1, "server_url": "ws://livekit:7880", "ice_servers": [...] } """ lesson_id = request.data.get('lesson_id') if not lesson_id: return Response( {'error': 'lesson_id обязателен'}, status=status.HTTP_400_BAD_REQUEST ) try: lesson = Lesson.objects.get(id=lesson_id) except Lesson.DoesNotExist: return Response( {'error': 'Занятие не найдено'}, status=status.HTTP_404_NOT_FOUND ) # Проверка прав доступа user = request.user if user != lesson.mentor and (not hasattr(user, 'client_profile') or user.client_profile != lesson.client): return Response( {'error': 'Нет доступа к этому занятию'}, status=status.HTTP_403_FORBIDDEN ) # Проверка времени подключения from django.utils import timezone from datetime import timedelta now = timezone.now() # Можно подключиться за 10 минут до начала урока allowed_start_time = lesson.start_time - timedelta(minutes=10) if now < allowed_start_time: minutes_until_allowed = int((allowed_start_time - now).total_seconds() / 60) return Response( {'error': f'Подключение к уроку будет доступно за 10 минут до начала ({allowed_start_time.strftime("%H:%M")}). Осталось {minutes_until_allowed} минут.'}, status=status.HTTP_403_FORBIDDEN ) # Нельзя подключиться позже 15 минут после окончания урока if lesson.end_time: # Используем фактическое время завершения, если оно есть, иначе запланированное actual_end_time = lesson.completed_at if (lesson.status == 'completed' and lesson.completed_at) else lesson.end_time allowed_end_time = actual_end_time + timedelta(minutes=15) if now > allowed_end_time: return Response( {'error': 'Доступ к видеокомнате закрыт. Прошло более 15 минут после завершения урока.'}, status=status.HTTP_403_FORBIDDEN ) # Если LiveKit комната не создана — создаём «на лету» (fallback при сбое при создании урока) if not lesson.livekit_room_name: try: try: existing = VideoRoom.objects.get(lesson=lesson) room_name = str(existing.room_id) except VideoRoom.DoesNotExist: room_name = LiveKitService.generate_room_name() client_user = lesson.client.user if hasattr(lesson.client, 'user') else lesson.client VideoRoom.objects.create( lesson=lesson, mentor=lesson.mentor, client=client_user, room_id=room_name, is_recording=True, max_participants=10 if lesson.group else 2, ) mentor_token = LiveKitService.generate_access_token( room_name=room_name, participant_name=lesson.mentor.get_full_name(), participant_identity=str(lesson.mentor.pk), is_admin=True, expires_in_minutes=1440, ) lesson.livekit_room_name = room_name lesson.livekit_access_token = mentor_token lesson.save(update_fields=['livekit_room_name', 'livekit_access_token']) logger.info(f'LiveKit room created on-demand for lesson {lesson.id}') except Exception: logger.exception(f'Failed to create LiveKit room for lesson {lesson.id}') return Response( {'error': 'LiveKit комната не создана для этого урока. Обратитесь к администратору.'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) room_name = lesson.livekit_room_name # Получаем или создаем VideoRoom запись try: video_room = VideoRoom.objects.get(lesson=lesson) except VideoRoom.DoesNotExist: client_user = lesson.client.user if hasattr(lesson.client, 'user') else lesson.client video_room = VideoRoom.objects.create( lesson=lesson, mentor=lesson.mentor, client=client_user, room_id=room_name, is_recording=True, max_participants=10 if lesson.group else 2 ) # Определяем роль пользователя is_admin = user == lesson.mentor # Получаем или создаём доску для пары ментор–студент (одна доска на пару, в metadata токена) mentor_id = lesson.mentor_id client_obj = lesson.client student_user = client_obj.user if hasattr(client_obj, 'user') else client_obj student_id = getattr(student_user, 'id', getattr(student_user, 'pk', None)) token_metadata = None if mentor_id and 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': lesson.mentor, } ) if created: board.participants.add(lesson.mentor, student_user) token_metadata = json.dumps({ 'board_id': str(board.board_id), 'is_mentor': is_admin, }) participant_identity = str(user.pk) if hasattr(user, 'pk') and user.pk else str(user.id) participant_name = user.get_full_name() or user.email or f"User {participant_identity}" access_token = LiveKitService.generate_access_token( room_name=room_name, participant_name=participant_name, participant_identity=participant_identity, is_admin=is_admin, expires_in_minutes=180, # 3 часа metadata=token_metadata, ) logger.info(f'LiveKit token generated for user {user.id} (identity={participant_identity}) in room {room_name}') # Получаем ICE серверы ice_servers = LiveKitService.get_ice_servers() server_url = LiveKitService.get_server_url(request=request) return Response({ 'room_name': str(room_name), 'access_token': access_token, 'server_url': server_url, 'ice_servers': ice_servers, 'video_room_id': video_room.id, 'is_admin': is_admin, 'lesson': { 'id': lesson.id, 'title': lesson.title, 'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None, 'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None, } }) @api_view(['GET']) @permission_classes([IsAuthenticated]) def get_livekit_config(request): """ Получение конфигурации LiveKit для фронтенда. GET /api/video/livekit/config/ Returns: { "server_url": "ws://livekit:7880", "ice_servers": [...] } """ return Response({ 'server_url': LiveKitService.get_server_url(request=request), 'ice_servers': LiveKitService.get_ice_servers(), }) @api_view(['DELETE']) @permission_classes([IsAuthenticated]) def delete_livekit_room_by_lesson(request, lesson_id): """ Удаление LiveKit видеокомнаты по ID занятия. DELETE /api/video/livekit/rooms/lesson/{lesson_id}/ Проверяет, что прошло минимум 10 минут после окончания занятия. Только ментор может удалять видеокомнату. """ from django.utils import timezone from datetime import timedelta try: lesson = Lesson.objects.get(id=lesson_id) except Lesson.DoesNotExist: return Response( {'error': 'Занятие не найдено'}, status=status.HTTP_404_NOT_FOUND ) # Проверка прав доступа - только ментор может удалять user = request.user if user != lesson.mentor: return Response( {'error': 'Только ментор может удалять видеокомнату'}, status=status.HTTP_403_FORBIDDEN ) # Проверка времени - можно удалять только через 10 минут после фактического окончания if lesson.end_time: now = timezone.now() # Используем фактическое время завершения, если оно есть, иначе запланированное actual_end_time = lesson.completed_at if (lesson.status == 'completed' and lesson.completed_at) else lesson.end_time allowed_delete_time = actual_end_time + timedelta(minutes=10) if now < allowed_delete_time: minutes_remaining = int((allowed_delete_time - now).total_seconds() / 60) return Response( { 'error': f'Видеокомнату можно удалить только через 10 минут после окончания занятия. Осталось {minutes_remaining} минут.' }, status=status.HTTP_400_BAD_REQUEST ) else: return Response( {'error': 'Время окончания занятия не указано'}, status=status.HTTP_400_BAD_REQUEST ) # Проверяем, есть ли VideoRoom для этого занятия try: video_room = VideoRoom.objects.get(lesson=lesson) # Удаляем комнату из SFU сервера (если используется) from .services import get_sfu_client, SFUClientError sfu_client = get_sfu_client() try: sfu_client.delete_room(str(video_room.room_id)) except SFUClientError as e: # Логируем ошибку, но продолжаем удаление из Django import logging logger = logging.getLogger(__name__) logger.warning(f'Не удалось удалить комнату из SFU сервера: {e}') # Удаляем комнату из базы данных video_room.delete() return Response( {'message': 'Видеокомната успешно удалена'}, status=status.HTTP_200_OK ) except VideoRoom.DoesNotExist: return Response( {'error': 'Видеокомната для этого занятия не найдена'}, status=status.HTTP_404_NOT_FOUND ) @api_view(['POST']) @permission_classes([IsAuthenticated]) def participant_connected(request): """ Отметить подключение участника к видеокомнате (для метрик). Вызывается фронтендом при успешном подключении к LiveKit комнате. POST /api/video/livekit/participant-connected/ Body: { "room_name": "uuid", "lesson_id": 123 } — lesson_id опционален, резерв при 404 по room_name """ room_name = request.data.get('room_name') lesson_id = request.data.get('lesson_id') if not room_name and not lesson_id: return Response( {'error': 'Укажите room_name или lesson_id'}, status=status.HTTP_400_BAD_REQUEST ) video_room = None if room_name: try: import uuid as uuid_module room_uuid = uuid_module.UUID(str(room_name).strip()) video_room = VideoRoom.objects.get(room_id=room_uuid) except (ValueError, VideoRoom.DoesNotExist): pass if not video_room and lesson_id: try: video_room = VideoRoom.objects.get(lesson_id=lesson_id) except VideoRoom.DoesNotExist: pass if not video_room: logger.warning( 'participant_connected: VideoRoom not found (room_name=%s, lesson_id=%s, user=%s)', room_name, lesson_id, getattr(request.user, 'pk', request.user) ) return Response( {'error': 'Видеокомната не найдена'}, status=status.HTTP_404_NOT_FOUND ) user = request.user client_user = video_room.client.user if hasattr(video_room.client, 'user') else video_room.client is_mentor = user.pk == video_room.mentor_id client_pk = getattr(client_user, 'pk', None) or getattr(client_user, 'id', None) is_client = client_pk is not None and user.pk == client_pk if not is_mentor and not is_client: return Response( {'error': 'Нет доступа к этой видеокомнате'}, status=status.HTTP_403_FORBIDDEN ) from .models import VideoParticipant participant, _ = VideoParticipant.objects.get_or_create( room=video_room, user=user, defaults={ 'is_connected': True, 'is_audio_enabled': True, 'is_video_enabled': True, } ) if not participant.is_connected: participant.is_connected = True participant.save(update_fields=['is_connected']) video_room.mark_participant_joined(user) logger.info( 'participant_connected: marked %s for lesson %s (room %s)', 'mentor' if is_mentor else 'client', video_room.lesson_id, video_room.room_id, ) return Response({'success': True}, status=status.HTTP_200_OK) @api_view(['POST']) @permission_classes([IsAuthenticated]) def update_livekit_participant_media_state(request): """ Обновление состояния медиа (камера/микрофон) участника LiveKit комнаты. POST /api/video/livekit/update-media-state/ Body: { "room_name": "uuid", "audio_enabled": true, "video_enabled": false } """ room_name = request.data.get('room_name') audio_enabled = request.data.get('audio_enabled') video_enabled = request.data.get('video_enabled') if not room_name: return Response( {'error': 'room_name обязателен'}, status=status.HTTP_400_BAD_REQUEST ) if audio_enabled is None and video_enabled is None: return Response( {'error': 'Необходимо указать хотя бы одно из: audio_enabled, video_enabled'}, status=status.HTTP_400_BAD_REQUEST ) try: # Находим комнату по room_name (это строка room_id) try: import uuid room_uuid = uuid.UUID(str(room_name)) video_room = VideoRoom.objects.get(room_id=room_uuid) except (ValueError, VideoRoom.DoesNotExist): return Response( {'error': 'Видеокомната не найдена'}, status=status.HTTP_404_NOT_FOUND ) # Проверяем права доступа user = request.user client_user = video_room.client.user if hasattr(video_room.client, 'user') else video_room.client if user != video_room.mentor and user != client_user: return Response( {'error': 'Нет доступа к этой видеокомнате'}, status=status.HTTP_403_FORBIDDEN ) # Находим или создаем участника from .models import VideoParticipant participant, created = VideoParticipant.objects.get_or_create( room=video_room, user=user, defaults={ 'is_connected': True, 'is_audio_enabled': audio_enabled if audio_enabled is not None else True, 'is_video_enabled': video_enabled if video_enabled is not None else True, } ) # Фиксируем подключение ментора/студента для метрик video_room.mark_participant_joined(user) if not created: # Обновляем существующего участника if audio_enabled is not None: participant.is_audio_enabled = audio_enabled if video_enabled is not None: participant.is_video_enabled = video_enabled if not participant.is_connected: participant.is_connected = True participant.save() return Response({ 'success': True, 'participant_id': participant.id, 'audio_enabled': participant.is_audio_enabled, 'video_enabled': participant.is_video_enabled, }, status=status.HTTP_200_OK) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f'Ошибка обновления медиа-состояния участника: {str(e)}') return Response( {'error': f'Ошибка обновления медиа-состояния: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR )