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);
};