uchill/backend/apps/video/models.py

569 lines
16 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.

"""
Модели для видеоконференций.
"""
from django.db import models
from django.utils import timezone
from datetime import timedelta
import uuid
import secrets
class VideoRoom(models.Model):
"""
Модель видеокомнаты.
Представляет виртуальную комнату для видеоконференции.
"""
STATUS_CHOICES = [
('waiting', 'Ожидание'),
('active', 'Активна'),
('ended', 'Завершена'),
]
SFU_TYPE_CHOICES = [
('ion-sfu', 'Ion SFU'),
('janus', 'Janus Gateway'),
]
# Уникальный идентификатор комнаты
room_id = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
verbose_name='ID комнаты'
)
sfu_type = models.CharField(
max_length=20,
choices=SFU_TYPE_CHOICES,
default='ion-sfu',
verbose_name='Тип SFU',
help_text='Используемый SFU сервер (ion-sfu или janus)'
)
# Токен доступа для входа в комнату
access_token = models.CharField(
max_length=64,
unique=True,
db_index=True,
blank=True,
verbose_name='Токен доступа',
help_text='Уникальный токен для входа в видеокомнату'
)
# Связь с занятием
lesson = models.OneToOneField(
'schedule.Lesson',
on_delete=models.CASCADE,
related_name='video_room',
verbose_name='Занятие'
)
# Участники
mentor = models.ForeignKey(
'users.User',
on_delete=models.CASCADE,
related_name='mentor_video_rooms',
limit_choices_to={'role': 'mentor'},
verbose_name='Ментор'
)
client = models.ForeignKey(
'users.User',
on_delete=models.CASCADE,
related_name='client_video_rooms',
verbose_name='Клиент'
)
# Статус
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='waiting',
verbose_name='Статус',
db_index=True
)
# Время
started_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Время начала'
)
ended_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Время окончания'
)
duration = models.IntegerField(
null=True,
blank=True,
verbose_name='Длительность (секунды)',
help_text='Фактическая длительность видеозвонка'
)
# Настройки
is_recording = models.BooleanField(
default=False,
verbose_name='Запись включена'
)
recording_url = models.URLField(
blank=True,
max_length=500,
verbose_name='Ссылка на запись'
)
# Mediasoup router ID (для управления)
router_id = models.CharField(
max_length=100,
blank=True,
verbose_name='Mediasoup Router ID'
)
# Статистика
mentor_joined_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Ментор подключился'
)
client_joined_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Клиент подключился'
)
max_participants = models.IntegerField(
default=2,
verbose_name='Максимум участников'
)
# Качество связи
quality_rating = models.IntegerField(
null=True,
blank=True,
verbose_name='Оценка качества связи',
help_text='От 1 до 5'
)
quality_issues = models.TextField(
blank=True,
verbose_name='Проблемы с качеством'
)
# Временные метки
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Дата создания'
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name='Дата обновления'
)
class Meta:
db_table = 'video_rooms'
verbose_name = 'Видеокомната'
verbose_name_plural = 'Видеокомнаты'
ordering = ['-created_at']
indexes = [
models.Index(fields=['room_id']),
models.Index(fields=['mentor', 'status']),
models.Index(fields=['client', 'status']),
models.Index(fields=['status', 'created_at']),
]
def __str__(self):
return f"Комната {self.room_id} - {self.lesson.title}"
def save(self, *args, **kwargs):
"""Генерируем токен доступа при создании комнаты"""
if not self.access_token or self.access_token == '':
self.access_token = secrets.token_urlsafe(32)
super().save(*args, **kwargs)
def get_join_url(self, request=None):
"""Получить URL для входа в комнату (на фронтенд)"""
# Всегда возвращаем URL фронтенда (порт 3000), а не бэкенда
from django.conf import settings
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
# Для всех типов (Janus, ion-sfu) используем общий путь
return f"{frontend_url}/video/join/{self.access_token}"
def start(self):
"""Начать видеозвонок."""
if self.status != 'waiting':
raise ValueError('Комната уже начата или завершена')
self.status = 'active'
self.started_at = timezone.now()
self.save()
def end(self):
"""Завершить видеозвонок."""
if self.status != 'active':
raise ValueError('Комната не активна')
self.status = 'ended'
self.ended_at = timezone.now()
# Вычисляем длительность
if self.started_at:
self.duration = int((self.ended_at - self.started_at).total_seconds())
self.save()
def mark_participant_joined(self, user):
"""Отметить что участник подключился (также обновляет Lesson для метрик)."""
now = timezone.now()
update_fields = []
user_pk = getattr(user, 'pk', None) or getattr(user, 'id', None)
if user_pk is not None and user_pk == self.mentor_id:
self.mentor_joined_at = now
update_fields.append('mentor_joined_at')
elif user_pk is not None and user_pk == self.client_id:
self.client_joined_at = now
update_fields.append('client_joined_at')
if update_fields:
self.save(update_fields=update_fields)
# Синхронизируем метрики на занятие для аналитики
lesson = self.lesson
if user_pk == self.mentor_id and not lesson.mentor_connected_at:
lesson.mentor_connected_at = now
lesson.save(update_fields=['mentor_connected_at'])
elif user_pk == self.client_id and not lesson.client_connected_at:
lesson.client_connected_at = now
lesson.save(update_fields=['client_connected_at'])
@property
def is_active(self):
"""Проверка активна ли комната."""
return self.status == 'active'
@property
def both_joined(self):
"""Проверка подключились ли оба участника."""
return self.mentor_joined_at is not None and self.client_joined_at is not None
@property
def actual_duration(self):
"""Получить фактическую длительность."""
if self.duration:
return self.duration
if self.started_at:
if self.ended_at:
return int((self.ended_at - self.started_at).total_seconds())
elif self.is_active:
return int((timezone.now() - self.started_at).total_seconds())
return 0
class VideoParticipant(models.Model):
"""
Модель участника видеозвонка.
Хранит информацию о подключении участника.
"""
room = models.ForeignKey(
VideoRoom,
on_delete=models.CASCADE,
related_name='participants',
verbose_name='Комната'
)
user = models.ForeignKey(
'users.User',
on_delete=models.CASCADE,
related_name='video_participations',
verbose_name='Пользователь'
)
# WebSocket connection ID
connection_id = models.CharField(
max_length=100,
blank=True,
verbose_name='Connection ID'
)
# Mediasoup transport IDs
send_transport_id = models.CharField(
max_length=100,
blank=True,
verbose_name='Send Transport ID'
)
recv_transport_id = models.CharField(
max_length=100,
blank=True,
verbose_name='Receive Transport ID'
)
# Статус участия
is_connected = models.BooleanField(
default=False,
verbose_name='Подключен'
)
is_audio_enabled = models.BooleanField(
default=True,
verbose_name='Аудио включено'
)
is_video_enabled = models.BooleanField(
default=True,
verbose_name='Видео включено'
)
is_screen_sharing = models.BooleanField(
default=False,
verbose_name='Демонстрация экрана'
)
# Время подключения
joined_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Время подключения'
)
left_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Время отключения'
)
# Статистика
total_duration = models.IntegerField(
default=0,
verbose_name='Общее время (секунды)'
)
reconnection_count = models.IntegerField(
default=0,
verbose_name='Количество переподключений'
)
class Meta:
db_table = 'video_participants'
verbose_name = 'Участник видео'
verbose_name_plural = 'Участники видео'
ordering = ['joined_at']
unique_together = ['room', 'user']
def __str__(self):
return f"{self.user.email} в комнате {self.room.room_id}"
def disconnect(self):
"""Отключить участника."""
self.is_connected = False
self.left_at = timezone.now()
# Обновляем общее время
if self.joined_at:
duration = (self.left_at - self.joined_at).total_seconds()
self.total_duration += int(duration)
self.save()
class VideoCallLog(models.Model):
"""
Лог видеозвонка.
Хранит детальную информацию о звонке для аналитики.
"""
room = models.ForeignKey(
VideoRoom,
on_delete=models.CASCADE,
related_name='call_logs',
verbose_name='Комната'
)
# Информация о звонке
total_participants = models.IntegerField(
default=0,
verbose_name='Всего участников'
)
total_duration = models.IntegerField(
default=0,
verbose_name='Общая длительность (секунды)'
)
# Качество
average_bitrate = models.IntegerField(
null=True,
blank=True,
verbose_name='Средний битрейт (kbps)'
)
packet_loss_rate = models.FloatField(
null=True,
blank=True,
verbose_name='Процент потери пакетов'
)
average_jitter = models.IntegerField(
null=True,
blank=True,
verbose_name='Средний jitter (ms)'
)
# Проблемы
connection_issues = models.IntegerField(
default=0,
verbose_name='Проблем с подключением'
)
audio_issues = models.IntegerField(
default=0,
verbose_name='Проблем с аудио'
)
video_issues = models.IntegerField(
default=0,
verbose_name='Проблем с видео'
)
# Дополнительная информация (JSON)
metadata = models.JSONField(
default=dict,
blank=True,
verbose_name='Метаданные'
)
# Временные метки
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Дата создания'
)
class Meta:
db_table = 'video_call_logs'
verbose_name = 'Лог видеозвонка'
verbose_name_plural = 'Логи видеозвонков'
ordering = ['-created_at']
def __str__(self):
return f"Лог звонка {self.room.room_id}"
class ScreenRecording(models.Model):
"""
Модель записи экрана/видео.
Хранит информацию о записях занятий.
"""
STATUS_CHOICES = [
('processing', 'Обработка'),
('ready', 'Готово'),
('failed', 'Ошибка'),
]
room = models.ForeignKey(
VideoRoom,
on_delete=models.CASCADE,
related_name='recordings',
verbose_name='Комната'
)
# Файл записи
file_path = models.CharField(
max_length=500,
blank=True,
verbose_name='Путь к файлу'
)
file_size = models.BigIntegerField(
null=True,
blank=True,
verbose_name='Размер файла (bytes)'
)
duration = models.IntegerField(
null=True,
blank=True,
verbose_name='Длительность (секунды)'
)
# URL для просмотра
url = models.URLField(
blank=True,
max_length=500,
verbose_name='URL записи'
)
# Статус обработки
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='processing',
verbose_name='Статус'
)
processing_error = models.TextField(
blank=True,
verbose_name='Ошибка обработки'
)
# Доступ
is_public = models.BooleanField(
default=False,
verbose_name='Публичная'
)
expires_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Истекает',
help_text='После этой даты запись будет удалена'
)
# Временные метки
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Дата создания'
)
processed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Дата обработки'
)
class Meta:
db_table = 'screen_recordings'
verbose_name = 'Запись видео'
verbose_name_plural = 'Записи видео'
ordering = ['-created_at']
def __str__(self):
return f"Запись {self.room.room_id}"
def mark_as_ready(self):
"""Отметить запись как готовую."""
self.status = 'ready'
self.processed_at = timezone.now()
self.save()
def mark_as_failed(self, error):
"""Отметить запись как ошибочную."""
self.status = 'failed'
self.processing_error = str(error)
self.processed_at = timezone.now()
self.save()
@property
def is_expired(self):
"""Проверка истекла ли запись."""
if self.expires_at:
return timezone.now() > self.expires_at
return False