bug fix
Deploy to Production / deploy-production (push) Successful in 49s Details

This commit is contained in:
root 2026-02-23 21:44:27 +03:00
parent 9382eab7b2
commit a167683bd9
12 changed files with 797 additions and 727 deletions

View File

@ -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"

View File

@ -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

View File

@ -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,

View File

@ -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,6 +380,7 @@ 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:
# Находим все запланированные занятия, которые должны начаться # Находим все запланированные занятия, которые должны начаться
@ -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')
# Оптимизация: используем bulk_update вместо цикла с save()
lessons_to_complete_list = list(lessons_to_complete) 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()
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}')

View File

@ -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)

View File

@ -222,10 +222,11 @@ class VideoRoom(models.Model):
"""Отметить что участник подключился (также обновляет Lesson для метрик).""" """Отметить что участник подключился (также обновляет Lesson для метрик)."""
now = timezone.now() now = timezone.now()
update_fields = [] update_fields = []
if user == self.mentor: 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 self.mentor_joined_at = now
update_fields.append('mentor_joined_at') update_fields.append('mentor_joined_at')
elif user == self.client: elif user_pk is not None and user_pk == self.client_id:
self.client_joined_at = now self.client_joined_at = now
update_fields.append('client_joined_at') update_fields.append('client_joined_at')
@ -233,10 +234,10 @@ class VideoRoom(models.Model):
self.save(update_fields=update_fields) self.save(update_fields=update_fields)
# Синхронизируем метрики на занятие для аналитики # Синхронизируем метрики на занятие для аналитики
lesson = self.lesson lesson = self.lesson
if user == self.mentor and not lesson.mentor_connected_at: if user_pk == self.mentor_id and not lesson.mentor_connected_at:
lesson.mentor_connected_at = now lesson.mentor_connected_at = now
lesson.save(update_fields=['mentor_connected_at']) lesson.save(update_fields=['mentor_connected_at'])
elif user == self.client and not lesson.client_connected_at: elif user_pk == self.client_id and not lesson.client_connected_at:
lesson.client_connected_at = now lesson.client_connected_at = now
lesson.save(update_fields=['client_connected_at']) lesson.save(update_fields=['client_connected_at'])

View File

@ -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
) )

View File

@ -82,6 +82,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': {
'task': 'apps.schedule.tasks.send_lesson_reminders', 'task': 'apps.schedule.tasks.send_lesson_reminders',

View File

@ -52,8 +52,15 @@ export async function getLiveKitConfig(): Promise<LiveKitConfig> {
} }
/** /**
* Отметить подключение участника к видеокомнате (для метрик) * Отметить подключение участника к видеокомнате (для метрик).
* lessonId резерв при 404 по room_name (например, если room.name отличается от БД).
*/ */
export async function participantConnected(roomName: string): Promise<void> { export async function participantConnected(params: {
await apiClient.post('/video/livekit/participant-connected/', { room_name: roomName }); 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);
} }

View File

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