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
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"⏱ <b>Занятие завершилось по времени</b>\n\n"
f"📚 <b>{lesson.title}</b>\n"

View File

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

View File

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

View File

@ -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,6 +380,7 @@ def start_lessons_automatically():
now = timezone.now()
started_count = 0
completed_count = 0
logger.info(f'[start_lessons_automatically] Запуск')
try:
# Находим все запланированные занятия, которые должны начаться
@ -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')
# Оптимизация: используем bulk_update вместо цикла с save()
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:
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}')

View File

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

View File

@ -222,10 +222,11 @@ class VideoRoom(models.Model):
"""Отметить что участник подключился (также обновляет Lesson для метрик)."""
now = timezone.now()
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
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
update_fields.append('client_joined_at')
@ -233,10 +234,10 @@ class VideoRoom(models.Model):
self.save(update_fields=update_fields)
# Синхронизируем метрики на занятие для аналитики
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.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.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
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
)

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 час
'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> {
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);
}

View File

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