""" Модели для видеоконференций. """ 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): """Отметить что участник подключился.""" if user == self.mentor: self.mentor_joined_at = timezone.now() elif user == self.client: self.client_joined_at = timezone.now() self.save(update_fields=['mentor_joined_at', 'client_joined_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