bug fix
Deploy to Production / deploy-production (push) Successful in 49s
Details
Deploy to Production / deploy-production (push) Successful in 49s
Details
This commit is contained in:
parent
9382eab7b2
commit
a167683bd9
|
|
@ -497,11 +497,13 @@ def _format_lesson_datetime_ru(dt, user_timezone='UTC'):
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@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
|
import asyncio
|
||||||
from apps.schedule.models import Lesson
|
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 import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from .telegram_bot import send_telegram_message_with_buttons
|
from .telegram_bot import send_telegram_message_with_buttons
|
||||||
|
|
||||||
|
logger.info(f'send_lesson_completion_confirmation_telegram: lesson_id={lesson_id}')
|
||||||
try:
|
try:
|
||||||
lesson = Lesson.objects.select_related('mentor', 'client', 'client__user').get(id=lesson_id)
|
lesson = Lesson.objects.select_related('mentor', 'client', 'client__user').get(id=lesson_id)
|
||||||
except Lesson.DoesNotExist:
|
except Lesson.DoesNotExist:
|
||||||
|
|
@ -516,7 +519,13 @@ def send_lesson_completion_confirmation_telegram(lesson_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
mentor = lesson.mentor
|
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
|
return
|
||||||
|
|
||||||
tz = mentor.timezone or 'UTC'
|
tz = mentor.timezone or 'UTC'
|
||||||
|
|
@ -545,6 +554,11 @@ def send_lesson_completion_confirmation_telegram(lesson_id):
|
||||||
mentor_status = '✅ Подключился' if mentor_connected else '❌ Не подключался'
|
mentor_status = '✅ Подключился' if mentor_connected else '❌ Не подключался'
|
||||||
client_status = '✅ Подключился' if client_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 = (
|
message = (
|
||||||
f"⏱ <b>Занятие завершилось по времени</b>\n\n"
|
f"⏱ <b>Занятие завершилось по времени</b>\n\n"
|
||||||
f"📚 <b>{lesson.title}</b>\n"
|
f"📚 <b>{lesson.title}</b>\n"
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
# Generated manually
|
# Generated manually
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("schedule", "0010_lesson_livekit_access_token_lesson_livekit_room_name_and_more"),
|
("schedule", "0010_lesson_livekit_access_token_lesson_livekit_room_name_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="lesson",
|
model_name="lesson",
|
||||||
name="mentor_connected_at",
|
name="mentor_connected_at",
|
||||||
field=models.DateTimeField(
|
field=models.DateTimeField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Время подключения ментора к видеокомнате",
|
help_text="Время подключения ментора к видеокомнате",
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Ментор подключился",
|
verbose_name="Ментор подключился",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="lesson",
|
model_name="lesson",
|
||||||
name="client_connected_at",
|
name="client_connected_at",
|
||||||
field=models.DateTimeField(
|
field=models.DateTimeField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Время подключения студента к видеокомнате",
|
help_text="Время подключения студента к видеокомнате",
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Студент подключился",
|
verbose_name="Студент подключился",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -633,12 +633,12 @@ class LessonCalendarSerializer(serializers.Serializer):
|
||||||
'end_date': 'Дата окончания должна быть позже даты начала'
|
'end_date': 'Дата окончания должна быть позже даты начала'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Ограничение диапазона (не более 3 месяцев)
|
# Ограничение диапазона (не более 6 месяцев — для календаря, смена месяцев)
|
||||||
if start_date and end_date:
|
if start_date and end_date:
|
||||||
delta = end_date - start_date
|
delta = end_date - start_date
|
||||||
if delta.days > 90:
|
if delta.days > 180:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
'Диапазон не может превышать 90 дней'
|
'Диапазон не может превышать 180 дней'
|
||||||
)
|
)
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from .models import Lesson
|
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)
|
@receiver(post_save, sender=Lesson)
|
||||||
|
|
@ -30,6 +30,9 @@ def lesson_saved(sender, instance, created, **kwargs):
|
||||||
lesson_id=instance.id,
|
lesson_id=instance.id,
|
||||||
notification_type='lesson_created'
|
notification_type='lesson_created'
|
||||||
)
|
)
|
||||||
|
# Если занятие создано сразу в статусе completed (задним числом) — отправляем подтверждение в Telegram
|
||||||
|
if instance.status == 'completed':
|
||||||
|
send_lesson_completion_confirmation_telegram.delay(instance.id)
|
||||||
# Напоминания отправляются периодической задачей send_lesson_reminders
|
# Напоминания отправляются периодической задачей send_lesson_reminders
|
||||||
# (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent)
|
# (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent)
|
||||||
else:
|
else:
|
||||||
|
|
@ -43,11 +46,16 @@ def lesson_saved(sender, instance, created, **kwargs):
|
||||||
)
|
)
|
||||||
|
|
||||||
if instance.tracker.has_changed('status'):
|
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
|
ref_time = instance.end_time or instance.start_time
|
||||||
if ref_time and ref_time < timezone.now():
|
if ref_time and ref_time < timezone.now():
|
||||||
return # Занятие уже прошло — уведомления не отправляем
|
return # Занятие уже прошло — пропускаем lesson_cancelled / lesson_completed
|
||||||
if instance.status == 'cancelled':
|
if instance.status == 'cancelled':
|
||||||
send_lesson_notification.delay(
|
send_lesson_notification.delay(
|
||||||
lesson_id=instance.id,
|
lesson_id=instance.id,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from celery import shared_task
|
||||||
import logging
|
import logging
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.db.models import Count
|
from django.db.models import Count, Q
|
||||||
from .models import Lesson, Subject, MentorSubject
|
from .models import Lesson, Subject, MentorSubject
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -380,7 +380,8 @@ def start_lessons_automatically():
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
started_count = 0
|
started_count = 0
|
||||||
completed_count = 0
|
completed_count = 0
|
||||||
|
logger.info(f'[start_lessons_automatically] Запуск')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Находим все запланированные занятия, которые должны начаться
|
# Находим все запланированные занятия, которые должны начаться
|
||||||
# start_time <= now (время начала уже наступило)
|
# start_time <= now (время начала уже наступило)
|
||||||
|
|
@ -402,17 +403,22 @@ def start_lessons_automatically():
|
||||||
for lesson in lessons_to_start_list:
|
for lesson in lessons_to_start_list:
|
||||||
logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"')
|
logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"')
|
||||||
|
|
||||||
# Находим занятия, которые уже прошли и должны быть завершены
|
# Находим занятия, которые уже прошли и должны быть завершены:
|
||||||
# end_time < now - 5 минут (время окончания прошло более 5 минут назад - даём время на завершение)
|
# end_time < now - 15 минут (всегда ждём 15 мин после конца, даже если занятие создали задним числом)
|
||||||
# status in ['scheduled', 'in_progress'] (еще не завершены)
|
fifteen_minutes_ago = now - timedelta(minutes=15)
|
||||||
five_minutes_ago = now - timedelta(minutes=5)
|
|
||||||
lessons_to_complete = Lesson.objects.filter(
|
lessons_to_complete = Lesson.objects.filter(
|
||||||
status__in=['scheduled', 'in_progress'],
|
status__in=['scheduled', 'in_progress'],
|
||||||
end_time__lt=five_minutes_ago
|
end_time__lt=fifteen_minutes_ago
|
||||||
).select_related('mentor', 'client')
|
).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()
|
# Оптимизация: используем bulk_update вместо цикла с save()
|
||||||
lessons_to_complete_list = list(lessons_to_complete)
|
|
||||||
for lesson in lessons_to_complete_list:
|
for lesson in lessons_to_complete_list:
|
||||||
lesson.status = 'completed'
|
lesson.status = 'completed'
|
||||||
lesson.completed_at = now
|
lesson.completed_at = now
|
||||||
|
|
@ -439,9 +445,10 @@ def start_lessons_automatically():
|
||||||
logger.error(f'Ошибка закрытия LiveKit комнаты для урока {lesson.id}: {str(e)}', exc_info=True)
|
logger.error(f'Ошибка закрытия LiveKit комнаты для урока {lesson.id}: {str(e)}', exc_info=True)
|
||||||
|
|
||||||
# Отправить ментору в Telegram сообщение с кнопками подтверждения
|
# Отправить ментору в Telegram сообщение с кнопками подтверждения
|
||||||
|
# (только если кто-то не подключался — при обоих подключённых ментор подтверждает при выходе)
|
||||||
try:
|
try:
|
||||||
from apps.notifications.tasks import send_lesson_completion_confirmation_telegram
|
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:
|
except Exception as e:
|
||||||
logger.warning(f'Не удалось отправить подтверждение занятия в Telegram: {e}')
|
logger.warning(f'Не удалось отправить подтверждение занятия в Telegram: {e}')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -283,20 +283,36 @@ def participant_connected(request):
|
||||||
Вызывается фронтендом при успешном подключении к LiveKit комнате.
|
Вызывается фронтендом при успешном подключении к LiveKit комнате.
|
||||||
|
|
||||||
POST /api/video/livekit/participant-connected/
|
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')
|
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(
|
return Response(
|
||||||
{'error': 'room_name обязателен'},
|
{'error': 'Укажите room_name или lesson_id'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
video_room = None
|
||||||
import uuid as uuid_module
|
if room_name:
|
||||||
room_uuid = uuid_module.UUID(str(room_name))
|
try:
|
||||||
video_room = VideoRoom.objects.get(room_id=room_uuid)
|
import uuid as uuid_module
|
||||||
except (ValueError, VideoRoom.DoesNotExist):
|
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(
|
return Response(
|
||||||
{'error': 'Видеокомната не найдена'},
|
{'error': 'Видеокомната не найдена'},
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
|
@ -304,7 +320,10 @@ def participant_connected(request):
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
client_user = video_room.client.user if hasattr(video_room.client, 'user') else video_room.client
|
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(
|
return Response(
|
||||||
{'error': 'Нет доступа к этой видеокомнате'},
|
{'error': 'Нет доступа к этой видеокомнате'},
|
||||||
status=status.HTTP_403_FORBIDDEN
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
|
@ -325,7 +344,12 @@ def participant_connected(request):
|
||||||
participant.save(update_fields=['is_connected'])
|
participant.save(update_fields=['is_connected'])
|
||||||
|
|
||||||
video_room.mark_participant_joined(user)
|
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)
|
return Response({'success': True}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -26,10 +26,11 @@ def create_video_room_for_lesson(sender, instance, created, **kwargs):
|
||||||
video_room = None
|
video_room = None
|
||||||
|
|
||||||
if not video_room:
|
if not video_room:
|
||||||
|
client_user = instance.client.user if hasattr(instance.client, 'user') else instance.client
|
||||||
video_room = VideoRoom.objects.create(
|
video_room = VideoRoom.objects.create(
|
||||||
lesson=instance,
|
lesson=instance,
|
||||||
mentor=instance.mentor,
|
mentor=instance.mentor,
|
||||||
client=instance.client,
|
client=client_user,
|
||||||
is_recording=True, # По умолчанию включаем запись
|
is_recording=True, # По умолчанию включаем запись
|
||||||
max_participants=2
|
max_participants=2
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,36 @@
|
||||||
"""
|
"""
|
||||||
URL routing для видео API.
|
URL routing для видео API.
|
||||||
"""
|
"""
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import (
|
from .views import (
|
||||||
VideoRoomViewSet,
|
VideoRoomViewSet,
|
||||||
VideoParticipantViewSet,
|
VideoParticipantViewSet,
|
||||||
VideoCallLogViewSet,
|
VideoCallLogViewSet,
|
||||||
ScreenRecordingViewSet
|
ScreenRecordingViewSet
|
||||||
)
|
)
|
||||||
from .janus_views import JanusVideoRoomViewSet
|
from .janus_views import JanusVideoRoomViewSet
|
||||||
from .token_views import VideoRoomTokenViewSet
|
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
|
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 = DefaultRouter()
|
||||||
router.register(r'rooms', VideoRoomViewSet, basename='videoroom')
|
router.register(r'rooms', VideoRoomViewSet, basename='videoroom')
|
||||||
router.register(r'participants', VideoParticipantViewSet, basename='videoparticipant')
|
router.register(r'participants', VideoParticipantViewSet, basename='videoparticipant')
|
||||||
router.register(r'logs', VideoCallLogViewSet, basename='videocalllog')
|
router.register(r'logs', VideoCallLogViewSet, basename='videocalllog')
|
||||||
router.register(r'recordings', ScreenRecordingViewSet, basename='screenrecording')
|
router.register(r'recordings', ScreenRecordingViewSet, basename='screenrecording')
|
||||||
|
|
||||||
# Janus Gateway endpoints (параллельно с ion-sfu)
|
# Janus Gateway endpoints (параллельно с ion-sfu)
|
||||||
router.register(r'janus', JanusVideoRoomViewSet, basename='janus-videoroom')
|
router.register(r'janus', JanusVideoRoomViewSet, basename='janus-videoroom')
|
||||||
|
|
||||||
# Token-based access (публичный доступ по токену)
|
# Token-based access (публичный доступ по токену)
|
||||||
router.register(r'token', VideoRoomTokenViewSet, basename='videoroom-token')
|
router.register(r'token', VideoRoomTokenViewSet, basename='videoroom-token')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
# LiveKit endpoints
|
# LiveKit endpoints
|
||||||
path('livekit/create-room/', create_livekit_room, name='livekit-create-room'),
|
path('livekit/create-room/', create_livekit_room, name='livekit-create-room'),
|
||||||
path('livekit/config/', get_livekit_config, name='livekit-config'),
|
path('livekit/config/', get_livekit_config, name='livekit-config'),
|
||||||
path('livekit/rooms/lesson/<int:lesson_id>/', delete_livekit_room_by_lesson, name='livekit-delete-room-by-lesson'),
|
path('livekit/rooms/lesson/<int:lesson_id>/', 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/update-media-state/', update_livekit_participant_media_state, name='livekit-update-media-state'),
|
||||||
path('livekit/participant-connected/', participant_connected, name='livekit-participant-connected'),
|
path('livekit/participant-connected/', participant_connected, name='livekit-participant-connected'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,12 @@ app.conf.beat_schedule = {
|
||||||
# ============================================
|
# ============================================
|
||||||
# ЗАДАЧИ РАСПИСАНИЯ
|
# ЗАДАЧИ РАСПИСАНИЯ
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
|
# Автоматическое начало и завершение занятий по времени (каждую минуту)
|
||||||
|
'start-lessons-automatically': {
|
||||||
|
'task': 'apps.schedule.tasks.start_lessons_automatically',
|
||||||
|
'schedule': 60.0, # каждые 60 секунд
|
||||||
|
},
|
||||||
|
|
||||||
# Отправка напоминаний о занятиях за 1 час
|
# Отправка напоминаний о занятиях за 1 час
|
||||||
'send-lesson-reminders': {
|
'send-lesson-reminders': {
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,66 @@
|
||||||
/**
|
/**
|
||||||
* API для работы с LiveKit
|
* API для работы с LiveKit
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import apiClient from '@/lib/api-client';
|
import apiClient from '@/lib/api-client';
|
||||||
|
|
||||||
export interface LiveKitRoomResponse {
|
export interface LiveKitRoomResponse {
|
||||||
room_name: string;
|
room_name: string;
|
||||||
ws_url: string;
|
ws_url: string;
|
||||||
access_token: string;
|
access_token: string;
|
||||||
server_url: string;
|
server_url: string;
|
||||||
ice_servers?: Array<{
|
ice_servers?: Array<{
|
||||||
urls: string[];
|
urls: string[];
|
||||||
username?: string;
|
username?: string;
|
||||||
credential?: string;
|
credential?: string;
|
||||||
}>;
|
}>;
|
||||||
video_room_id?: number;
|
video_room_id?: number;
|
||||||
is_admin?: boolean;
|
is_admin?: boolean;
|
||||||
lesson?: {
|
lesson?: {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveKitConfig {
|
export interface LiveKitConfig {
|
||||||
server_url: string;
|
server_url: string;
|
||||||
ice_servers?: Array<{
|
ice_servers?: Array<{
|
||||||
urls: string[];
|
urls: string[];
|
||||||
username?: string;
|
username?: string;
|
||||||
credential?: string;
|
credential?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создать LiveKit комнату для занятия
|
* Создать LiveKit комнату для занятия
|
||||||
*/
|
*/
|
||||||
export async function createLiveKitRoom(lessonId: number): Promise<LiveKitRoomResponse> {
|
export async function createLiveKitRoom(lessonId: number): Promise<LiveKitRoomResponse> {
|
||||||
const res = await apiClient.post<LiveKitRoomResponse>('/video/livekit/create-room/', {
|
const res = await apiClient.post<LiveKitRoomResponse>('/video/livekit/create-room/', {
|
||||||
lesson_id: lessonId,
|
lesson_id: lessonId,
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить конфигурацию LiveKit
|
* Получить конфигурацию LiveKit
|
||||||
*/
|
*/
|
||||||
export async function getLiveKitConfig(): Promise<LiveKitConfig> {
|
export async function getLiveKitConfig(): Promise<LiveKitConfig> {
|
||||||
const res = await apiClient.get<LiveKitConfig>('/video/livekit/config/');
|
const res = await apiClient.get<LiveKitConfig>('/video/livekit/config/');
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отметить подключение участника к видеокомнате (для метрик)
|
* Отметить подключение участника к видеокомнате (для метрик).
|
||||||
*/
|
* lessonId — резерв при 404 по room_name (например, если room.name отличается от БД).
|
||||||
export async function participantConnected(roomName: string): Promise<void> {
|
*/
|
||||||
await apiClient.post('/video/livekit/participant-connected/', { room_name: roomName });
|
export async function participantConnected(params: {
|
||||||
}
|
roomName: string;
|
||||||
|
lessonId?: number | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -447,13 +447,15 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard,
|
||||||
getNavBadges().then(setNavBadges).catch(() => setNavBadges(null));
|
getNavBadges().then(setNavBadges).catch(() => setNavBadges(null));
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// Фиксируем подключение ментора/студента для метрик
|
// Фиксируем подключение ментора/студента для метрик (lessonId — резервный поиск комнаты)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onConnected = () => {
|
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);
|
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 () => {
|
return () => {
|
||||||
room.off(RoomEvent.Connected, onConnected);
|
room.off(RoomEvent.Connected, onConnected);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue