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
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
video_room = None
|
||||
if room_name:
|
||||
try:
|
||||
import uuid as uuid_module
|
||||
room_uuid = uuid_module.UUID(str(room_name))
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue