uchill/backend/apps/video/livekit_views.py

366 lines
14 KiB
Python
Raw 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 для работы с 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 комната для этого занятия
if not lesson.livekit_room_name:
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 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,
}
)
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
)