From a167683bd9c39a7db08d7ebd79bccc9ba2d77d63 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 23 Feb 2026 21:44:27 +0300 Subject: [PATCH] bug fix --- backend/apps/notifications/tasks.py | 22 +- .../0011_add_mentor_client_connected_at.py | 64 +- backend/apps/schedule/serializers.py | 6 +- backend/apps/schedule/signals.py | 14 +- backend/apps/schedule/tasks.py | 25 +- backend/apps/video/livekit_views.py | 44 +- backend/apps/video/models.py | 1135 +++++++++-------- backend/apps/video/signals.py | 3 +- backend/apps/video/urls.py | 72 +- backend/config/celery.py | 6 + front_material/api/livekit.ts | 125 +- .../components/livekit/LiveKitRoomContent.tsx | 8 +- 12 files changed, 797 insertions(+), 727 deletions(-) diff --git a/backend/apps/notifications/tasks.py b/backend/apps/notifications/tasks.py index bff4ae4..d9e99fd 100644 --- a/backend/apps/notifications/tasks.py +++ b/backend/apps/notifications/tasks.py @@ -497,11 +497,13 @@ def _format_lesson_datetime_ru(dt, user_timezone='UTC'): @shared_task -def send_lesson_completion_confirmation_telegram(lesson_id): +def send_lesson_completion_confirmation_telegram(lesson_id, only_if_someone_not_connected=False): """ - Отправить ментору в Telegram сообщение о завершённом по времени занятии + Отправить ментору в Telegram сообщение о завершённом занятии с кнопками «Занятие состоялось» / «Занятие отменилось». - Вызывается при авто-завершении занятия Celery-задачей. + + only_if_someone_not_connected: при True — отправить только если ментор или ученик не подключались + (при авто-завершении Celery). При False — всегда отправить (ручное завершение, сигнал). """ import asyncio from apps.schedule.models import Lesson @@ -509,6 +511,7 @@ def send_lesson_completion_confirmation_telegram(lesson_id): from telegram import InlineKeyboardButton, InlineKeyboardMarkup from .telegram_bot import send_telegram_message_with_buttons + logger.info(f'send_lesson_completion_confirmation_telegram: lesson_id={lesson_id}') try: lesson = Lesson.objects.select_related('mentor', 'client', 'client__user').get(id=lesson_id) except Lesson.DoesNotExist: @@ -516,7 +519,13 @@ def send_lesson_completion_confirmation_telegram(lesson_id): return mentor = lesson.mentor - if not mentor or not mentor.telegram_id: + if not mentor: + logger.warning(f'Lesson {lesson_id}: mentor is None, skipping completion confirmation') + return + if not mentor.telegram_id: + logger.warning( + f'Lesson {lesson_id}: mentor {mentor.id} has no telegram_id linked, skipping completion confirmation' + ) return tz = mentor.timezone or 'UTC' @@ -545,6 +554,11 @@ def send_lesson_completion_confirmation_telegram(lesson_id): mentor_status = '✅ Подключился' if mentor_connected else '❌ Не подключался' client_status = '✅ Подключился' if client_connected else '❌ Не подключался' + someone_not_connected = not mentor_connected or not client_connected + if only_if_someone_not_connected and not someone_not_connected: + logger.info(f'Lesson {lesson_id}: both participants connected, skipping completion confirmation') + return + message = ( f"⏱ Занятие завершилось по времени\n\n" f"📚 {lesson.title}\n" diff --git a/backend/apps/schedule/migrations/0011_add_mentor_client_connected_at.py b/backend/apps/schedule/migrations/0011_add_mentor_client_connected_at.py index 3dc3673..44f1d7f 100644 --- a/backend/apps/schedule/migrations/0011_add_mentor_client_connected_at.py +++ b/backend/apps/schedule/migrations/0011_add_mentor_client_connected_at.py @@ -1,32 +1,32 @@ -# Generated manually - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("schedule", "0010_lesson_livekit_access_token_lesson_livekit_room_name_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="lesson", - name="mentor_connected_at", - field=models.DateTimeField( - blank=True, - help_text="Время подключения ментора к видеокомнате", - null=True, - verbose_name="Ментор подключился", - ), - ), - migrations.AddField( - model_name="lesson", - name="client_connected_at", - field=models.DateTimeField( - blank=True, - help_text="Время подключения студента к видеокомнате", - null=True, - verbose_name="Студент подключился", - ), - ), - ] +# Generated manually + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("schedule", "0010_lesson_livekit_access_token_lesson_livekit_room_name_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="lesson", + name="mentor_connected_at", + field=models.DateTimeField( + blank=True, + help_text="Время подключения ментора к видеокомнате", + null=True, + verbose_name="Ментор подключился", + ), + ), + migrations.AddField( + model_name="lesson", + name="client_connected_at", + field=models.DateTimeField( + blank=True, + help_text="Время подключения студента к видеокомнате", + null=True, + verbose_name="Студент подключился", + ), + ), + ] diff --git a/backend/apps/schedule/serializers.py b/backend/apps/schedule/serializers.py index ef37fd0..b8c0205 100644 --- a/backend/apps/schedule/serializers.py +++ b/backend/apps/schedule/serializers.py @@ -633,12 +633,12 @@ class LessonCalendarSerializer(serializers.Serializer): 'end_date': 'Дата окончания должна быть позже даты начала' }) - # Ограничение диапазона (не более 3 месяцев) + # Ограничение диапазона (не более 6 месяцев — для календаря, смена месяцев) if start_date and end_date: delta = end_date - start_date - if delta.days > 90: + if delta.days > 180: raise serializers.ValidationError( - 'Диапазон не может превышать 90 дней' + 'Диапазон не может превышать 180 дней' ) return attrs diff --git a/backend/apps/schedule/signals.py b/backend/apps/schedule/signals.py index 76ed4a8..72a4674 100644 --- a/backend/apps/schedule/signals.py +++ b/backend/apps/schedule/signals.py @@ -9,7 +9,7 @@ from django.utils import timezone from datetime import timedelta from .models import Lesson -from apps.notifications.tasks import send_lesson_notification +from apps.notifications.tasks import send_lesson_notification, send_lesson_completion_confirmation_telegram @receiver(post_save, sender=Lesson) @@ -30,6 +30,9 @@ def lesson_saved(sender, instance, created, **kwargs): lesson_id=instance.id, notification_type='lesson_created' ) + # Если занятие создано сразу в статусе completed (задним числом) — отправляем подтверждение в Telegram + if instance.status == 'completed': + send_lesson_completion_confirmation_telegram.delay(instance.id) # Напоминания отправляются периодической задачей send_lesson_reminders # (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent) else: @@ -43,11 +46,16 @@ def lesson_saved(sender, instance, created, **kwargs): ) if instance.tracker.has_changed('status'): - # Статус изменился — не уведомляем, если занятие уже в прошлом + # При переводе в completed — всегда отправляем подтверждение в Telegram (с кнопками «состоялось»/«отменилось») + # Работает для ручного завершения и для занятий, созданных/завершённых задним числом + if instance.status == 'completed': + send_lesson_completion_confirmation_telegram.delay(instance.id) + + # Общие уведомления — не отправляем, если занятие уже в прошлом # (коррекция статуса/стоимости после факта, например отмена задним числом) ref_time = instance.end_time or instance.start_time if ref_time and ref_time < timezone.now(): - return # Занятие уже прошло — уведомления не отправляем + return # Занятие уже прошло — пропускаем lesson_cancelled / lesson_completed if instance.status == 'cancelled': send_lesson_notification.delay( lesson_id=instance.id, diff --git a/backend/apps/schedule/tasks.py b/backend/apps/schedule/tasks.py index b1857df..61d641a 100644 --- a/backend/apps/schedule/tasks.py +++ b/backend/apps/schedule/tasks.py @@ -4,7 +4,7 @@ from celery import shared_task import logging from django.utils import timezone from datetime import timedelta -from django.db.models import Count +from django.db.models import Count, Q from .models import Lesson, Subject, MentorSubject logger = logging.getLogger(__name__) @@ -380,7 +380,8 @@ def start_lessons_automatically(): now = timezone.now() started_count = 0 completed_count = 0 - + logger.info(f'[start_lessons_automatically] Запуск') + try: # Находим все запланированные занятия, которые должны начаться # start_time <= now (время начала уже наступило) @@ -402,17 +403,22 @@ def start_lessons_automatically(): for lesson in lessons_to_start_list: logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"') - # Находим занятия, которые уже прошли и должны быть завершены - # end_time < now - 5 минут (время окончания прошло более 5 минут назад - даём время на завершение) - # status in ['scheduled', 'in_progress'] (еще не завершены) - five_minutes_ago = now - timedelta(minutes=5) + # Находим занятия, которые уже прошли и должны быть завершены: + # end_time < now - 15 минут (всегда ждём 15 мин после конца, даже если занятие создали задним числом) + fifteen_minutes_ago = now - timedelta(minutes=15) lessons_to_complete = Lesson.objects.filter( status__in=['scheduled', 'in_progress'], - end_time__lt=five_minutes_ago + end_time__lt=fifteen_minutes_ago ).select_related('mentor', 'client') + + lessons_to_complete_list = list(lessons_to_complete) + if lessons_to_complete_list: + logger.info( + f'[start_lessons_automatically] Найдено {len(lessons_to_complete_list)} занятий ' + f'для завершения (end_time < {fifteen_minutes_ago})' + ) # Оптимизация: используем bulk_update вместо цикла с save() - lessons_to_complete_list = list(lessons_to_complete) for lesson in lessons_to_complete_list: lesson.status = 'completed' lesson.completed_at = now @@ -439,9 +445,10 @@ def start_lessons_automatically(): logger.error(f'Ошибка закрытия LiveKit комнаты для урока {lesson.id}: {str(e)}', exc_info=True) # Отправить ментору в Telegram сообщение с кнопками подтверждения + # (только если кто-то не подключался — при обоих подключённых ментор подтверждает при выходе) try: from apps.notifications.tasks import send_lesson_completion_confirmation_telegram - send_lesson_completion_confirmation_telegram.delay(lesson.id) + send_lesson_completion_confirmation_telegram.delay(lesson.id, only_if_someone_not_connected=True) except Exception as e: logger.warning(f'Не удалось отправить подтверждение занятия в Telegram: {e}') diff --git a/backend/apps/video/livekit_views.py b/backend/apps/video/livekit_views.py index abb1a79..0a0c8ce 100644 --- a/backend/apps/video/livekit_views.py +++ b/backend/apps/video/livekit_views.py @@ -283,20 +283,36 @@ def participant_connected(request): Вызывается фронтендом при успешном подключении к LiveKit комнате. POST /api/video/livekit/participant-connected/ - Body: { "room_name": "uuid" } + Body: { "room_name": "uuid", "lesson_id": 123 } — lesson_id опционален, резерв при 404 по room_name """ room_name = request.data.get('room_name') - if not room_name: + lesson_id = request.data.get('lesson_id') + + if not room_name and not lesson_id: return Response( - {'error': 'room_name обязателен'}, + {'error': 'Укажите room_name или lesson_id'}, status=status.HTTP_400_BAD_REQUEST ) - try: - import uuid as uuid_module - room_uuid = uuid_module.UUID(str(room_name)) - video_room = VideoRoom.objects.get(room_id=room_uuid) - except (ValueError, VideoRoom.DoesNotExist): + 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 @@ -304,7 +320,10 @@ def participant_connected(request): 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: + 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 @@ -325,7 +344,12 @@ def participant_connected(request): 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) diff --git a/backend/apps/video/models.py b/backend/apps/video/models.py index d5dfeb7..3deed62 100644 --- a/backend/apps/video/models.py +++ b/backend/apps/video/models.py @@ -1,567 +1,568 @@ -""" -Модели для видеоконференций. -""" -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 +""" +Модели для видеоконференций. +""" +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 diff --git a/backend/apps/video/signals.py b/backend/apps/video/signals.py index b87e204..a8c7029 100644 --- a/backend/apps/video/signals.py +++ b/backend/apps/video/signals.py @@ -26,10 +26,11 @@ def create_video_room_for_lesson(sender, instance, created, **kwargs): video_room = None if not video_room: + client_user = instance.client.user if hasattr(instance.client, 'user') else instance.client video_room = VideoRoom.objects.create( lesson=instance, mentor=instance.mentor, - client=instance.client, + client=client_user, is_recording=True, # По умолчанию включаем запись max_participants=2 ) diff --git a/backend/apps/video/urls.py b/backend/apps/video/urls.py index 668addc..ac30a7c 100644 --- a/backend/apps/video/urls.py +++ b/backend/apps/video/urls.py @@ -1,36 +1,36 @@ -""" -URL routing для видео API. -""" -from django.urls import path, include -from rest_framework.routers import DefaultRouter -from .views import ( - VideoRoomViewSet, - VideoParticipantViewSet, - VideoCallLogViewSet, - ScreenRecordingViewSet -) -from .janus_views import JanusVideoRoomViewSet -from .token_views import VideoRoomTokenViewSet -from .livekit_views import create_livekit_room, get_livekit_config, delete_livekit_room_by_lesson, update_livekit_participant_media_state, participant_connected - -router = DefaultRouter() -router.register(r'rooms', VideoRoomViewSet, basename='videoroom') -router.register(r'participants', VideoParticipantViewSet, basename='videoparticipant') -router.register(r'logs', VideoCallLogViewSet, basename='videocalllog') -router.register(r'recordings', ScreenRecordingViewSet, basename='screenrecording') - -# Janus Gateway endpoints (параллельно с ion-sfu) -router.register(r'janus', JanusVideoRoomViewSet, basename='janus-videoroom') - -# Token-based access (публичный доступ по токену) -router.register(r'token', VideoRoomTokenViewSet, basename='videoroom-token') - -urlpatterns = [ - path('', include(router.urls)), - # LiveKit endpoints - path('livekit/create-room/', create_livekit_room, name='livekit-create-room'), - path('livekit/config/', get_livekit_config, name='livekit-config'), - path('livekit/rooms/lesson//', delete_livekit_room_by_lesson, name='livekit-delete-room-by-lesson'), - path('livekit/update-media-state/', update_livekit_participant_media_state, name='livekit-update-media-state'), - path('livekit/participant-connected/', participant_connected, name='livekit-participant-connected'), -] +""" +URL routing для видео API. +""" +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import ( + VideoRoomViewSet, + VideoParticipantViewSet, + VideoCallLogViewSet, + ScreenRecordingViewSet +) +from .janus_views import JanusVideoRoomViewSet +from .token_views import VideoRoomTokenViewSet +from .livekit_views import create_livekit_room, get_livekit_config, delete_livekit_room_by_lesson, update_livekit_participant_media_state, participant_connected + +router = DefaultRouter() +router.register(r'rooms', VideoRoomViewSet, basename='videoroom') +router.register(r'participants', VideoParticipantViewSet, basename='videoparticipant') +router.register(r'logs', VideoCallLogViewSet, basename='videocalllog') +router.register(r'recordings', ScreenRecordingViewSet, basename='screenrecording') + +# Janus Gateway endpoints (параллельно с ion-sfu) +router.register(r'janus', JanusVideoRoomViewSet, basename='janus-videoroom') + +# Token-based access (публичный доступ по токену) +router.register(r'token', VideoRoomTokenViewSet, basename='videoroom-token') + +urlpatterns = [ + path('', include(router.urls)), + # LiveKit endpoints + path('livekit/create-room/', create_livekit_room, name='livekit-create-room'), + path('livekit/config/', get_livekit_config, name='livekit-config'), + path('livekit/rooms/lesson//', delete_livekit_room_by_lesson, name='livekit-delete-room-by-lesson'), + path('livekit/update-media-state/', update_livekit_participant_media_state, name='livekit-update-media-state'), + path('livekit/participant-connected/', participant_connected, name='livekit-participant-connected'), +] diff --git a/backend/config/celery.py b/backend/config/celery.py index 1e0aa48..8f7933c 100644 --- a/backend/config/celery.py +++ b/backend/config/celery.py @@ -81,6 +81,12 @@ app.conf.beat_schedule = { # ============================================ # ЗАДАЧИ РАСПИСАНИЯ # ============================================ + + # Автоматическое начало и завершение занятий по времени (каждую минуту) + 'start-lessons-automatically': { + 'task': 'apps.schedule.tasks.start_lessons_automatically', + 'schedule': 60.0, # каждые 60 секунд + }, # Отправка напоминаний о занятиях за 1 час 'send-lesson-reminders': { diff --git a/front_material/api/livekit.ts b/front_material/api/livekit.ts index 091ed9d..f35f46a 100644 --- a/front_material/api/livekit.ts +++ b/front_material/api/livekit.ts @@ -1,59 +1,66 @@ -/** - * API для работы с LiveKit - */ - -import apiClient from '@/lib/api-client'; - -export interface LiveKitRoomResponse { - room_name: string; - ws_url: string; - access_token: string; - server_url: string; - ice_servers?: Array<{ - urls: string[]; - username?: string; - credential?: string; - }>; - video_room_id?: number; - is_admin?: boolean; - lesson?: { - id: number; - title: string; - start_time: string; - end_time: string; - }; -} - -export interface LiveKitConfig { - server_url: string; - ice_servers?: Array<{ - urls: string[]; - username?: string; - credential?: string; - }>; -} - -/** - * Создать LiveKit комнату для занятия - */ -export async function createLiveKitRoom(lessonId: number): Promise { - const res = await apiClient.post('/video/livekit/create-room/', { - lesson_id: lessonId, - }); - return res.data; -} - -/** - * Получить конфигурацию LiveKit - */ -export async function getLiveKitConfig(): Promise { - const res = await apiClient.get('/video/livekit/config/'); - return res.data; -} - -/** - * Отметить подключение участника к видеокомнате (для метрик) - */ -export async function participantConnected(roomName: string): Promise { - await apiClient.post('/video/livekit/participant-connected/', { room_name: roomName }); -} +/** + * API для работы с LiveKit + */ + +import apiClient from '@/lib/api-client'; + +export interface LiveKitRoomResponse { + room_name: string; + ws_url: string; + access_token: string; + server_url: string; + ice_servers?: Array<{ + urls: string[]; + username?: string; + credential?: string; + }>; + video_room_id?: number; + is_admin?: boolean; + lesson?: { + id: number; + title: string; + start_time: string; + end_time: string; + }; +} + +export interface LiveKitConfig { + server_url: string; + ice_servers?: Array<{ + urls: string[]; + username?: string; + credential?: string; + }>; +} + +/** + * Создать LiveKit комнату для занятия + */ +export async function createLiveKitRoom(lessonId: number): Promise { + const res = await apiClient.post('/video/livekit/create-room/', { + lesson_id: lessonId, + }); + return res.data; +} + +/** + * Получить конфигурацию LiveKit + */ +export async function getLiveKitConfig(): Promise { + const res = await apiClient.get('/video/livekit/config/'); + return res.data; +} + +/** + * Отметить подключение участника к видеокомнате (для метрик). + * lessonId — резерв при 404 по room_name (например, если room.name отличается от БД). + */ +export async function participantConnected(params: { + roomName: string; + lessonId?: number | null; +}): Promise { + const { roomName, lessonId } = params; + const body: { room_name: string; lesson_id?: number } = { room_name: roomName }; + if (lessonId != null) body.lesson_id = lessonId; + await apiClient.post('/video/livekit/participant-connected/', body); +} diff --git a/front_material/components/livekit/LiveKitRoomContent.tsx b/front_material/components/livekit/LiveKitRoomContent.tsx index fa65889..b9f08de 100644 --- a/front_material/components/livekit/LiveKitRoomContent.tsx +++ b/front_material/components/livekit/LiveKitRoomContent.tsx @@ -447,13 +447,15 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard, getNavBadges().then(setNavBadges).catch(() => setNavBadges(null)); }, [user]); - // Фиксируем подключение ментора/студента для метрик + // Фиксируем подключение ментора/студента для метрик (lessonId — резервный поиск комнаты) useEffect(() => { const onConnected = () => { - if (room.name) participantConnected(room.name).catch(() => {}); + if (room.name || lessonId) + participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {}); }; room.on(RoomEvent.Connected, onConnected); - if (room.state === 'connected' && room.name) participantConnected(room.name).catch(() => {}); + if (room.state === 'connected' && (room.name || lessonId)) + participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {}); return () => { room.off(RoomEvent.Connected, onConnected); };