568 lines
17 KiB
Python
568 lines
17 KiB
Python
"""
|
||
Модели для видеоконференций.
|
||
"""
|
||
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 = []
|
||
if user == self.mentor:
|
||
self.mentor_joined_at = now
|
||
update_fields.append('mentor_joined_at')
|
||
elif user == self.client:
|
||
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 == self.mentor and not lesson.mentor_connected_at:
|
||
lesson.mentor_connected_at = now
|
||
lesson.save(update_fields=['mentor_connected_at'])
|
||
elif user == self.client 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
|