# Celery задачи для schedule from celery import shared_task import logging from django.utils import timezone from datetime import timedelta from django.db.models import Count, Q from .models import Lesson, Subject, MentorSubject logger = logging.getLogger(__name__) @shared_task def send_lesson_reminders(): """ Отправка напоминаний о предстоящих занятиях. Отправляет напоминания за: - 24 часа до занятия - 1 час до занятия - 15 минут до занятия Задача запускается каждые 15 минут через Celery Beat. """ from apps.notifications.services import NotificationService now = timezone.now() sent_24h = 0 sent_1h = 0 sent_15m = 0 try: # Находим все запланированные занятия, которые еще не начались и не отменены lessons = Lesson.objects.filter( start_time__gt=now, status='scheduled' ).select_related('client', 'client__user', 'mentor') # Напоминания за 24 часа (от 23:30 до 24:30) time_24h_min = now + timedelta(hours=23, minutes=30) time_24h_max = now + timedelta(hours=24, minutes=30) lessons_24h = lessons.filter( start_time__gte=time_24h_min, start_time__lte=time_24h_max, reminder_24h_sent=False ) # Оптимизация: используем bulk_update вместо цикла с save() lessons_24h_list = list(lessons_24h) lessons_24h_to_update = [] for lesson in lessons_24h_list: try: NotificationService.send_lesson_reminder(lesson, time_before="24 часа") lesson.reminder_24h_sent = True lessons_24h_to_update.append(lesson) sent_24h += 1 logger.info(f'Отправлено напоминание за 24 часа для занятия {lesson.id}') except Exception as e: logger.error(f'Ошибка отправки напоминания за 24 часа для занятия {lesson.id}: {e}') if lessons_24h_to_update: Lesson.objects.bulk_update(lessons_24h_to_update, ['reminder_24h_sent']) # Напоминания за 1 час (от 50 минут до 70 минут) time_1h_min = now + timedelta(minutes=50) time_1h_max = now + timedelta(minutes=70) lessons_1h = lessons.filter( start_time__gte=time_1h_min, start_time__lte=time_1h_max, reminder_1h_sent=False ) # Оптимизация: используем bulk_update вместо цикла с save() lessons_1h_list = list(lessons_1h) lessons_1h_to_update = [] for lesson in lessons_1h_list: try: NotificationService.send_lesson_reminder(lesson, time_before="1 час") lesson.reminder_1h_sent = True lessons_1h_to_update.append(lesson) sent_1h += 1 logger.info(f'Отправлено напоминание за 1 час для занятия {lesson.id}') except Exception as e: logger.error(f'Ошибка отправки напоминания за 1 час для занятия {lesson.id}: {e}') if lessons_1h_to_update: Lesson.objects.bulk_update(lessons_1h_to_update, ['reminder_1h_sent']) # Напоминания за 15 минут (от 10 минут до 20 минут) time_15m_min = now + timedelta(minutes=10) time_15m_max = now + timedelta(minutes=20) lessons_15m = lessons.filter( start_time__gte=time_15m_min, start_time__lte=time_15m_max, reminder_15m_sent=False ) # Оптимизация: используем bulk_update вместо цикла с save() lessons_15m_list = list(lessons_15m) lessons_15m_to_update = [] for lesson in lessons_15m_list: try: NotificationService.send_lesson_reminder(lesson, time_before="15 минут") lesson.reminder_15m_sent = True lessons_15m_to_update.append(lesson) sent_15m += 1 logger.info(f'Отправлено напоминание за 15 минут для занятия {lesson.id}') except Exception as e: logger.error(f'Ошибка отправки напоминания за 15 минут для занятия {lesson.id}: {e}') if lessons_15m_to_update: Lesson.objects.bulk_update(lessons_15m_to_update, ['reminder_15m_sent']) total_sent = sent_24h + sent_1h + sent_15m logger.info( f'[send_lesson_reminders] Отправлено напоминаний: ' f'24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m} (всего: {total_sent})' ) return f'Отправлено: 24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m}' except Exception as e: logger.error(f'[send_lesson_reminders] Ошибка: {str(e)}', exc_info=True) raise @shared_task def send_attendance_confirmation_requests(): """ Отправка запросов о подтверждении присутствия за 3 часа до занятия. Проверяет все занятия, которые начинаются через 3 часа или меньше, и отправляет запрос студенту, если еще не отправлен. """ from django.utils import timezone from datetime import timedelta from apps.notifications.services import NotificationService now = timezone.now() # Занятия, которые начинаются через 3 часа или меньше time_threshold = now + timedelta(hours=3) # Находим занятия, которые: # 1. Еще не начались (start_time > now) # 2. Начинаются через 3 часа или меньше (start_time <= time_threshold) # 3. Еще не отменены # 4. Запрос о присутствии еще не отправлен lessons = Lesson.objects.filter( start_time__gt=now, start_time__lte=time_threshold, status='scheduled', attendance_confirmation_sent=False ).select_related('client', 'client__user', 'mentor') sent_count = 0 lessons_to_update = [] for lesson in lessons: try: # Отправляем запрос NotificationService.send_attendance_confirmation_request(lesson) # Отмечаем что запрос отправлен (накапливаем для bulk_update) lesson.attendance_confirmation_sent = True lessons_to_update.append(lesson) sent_count += 1 logger.info(f'Отправлен запрос о присутствии для занятия {lesson.id}') except Exception as e: logger.error(f'Ошибка отправки запроса о присутствии для занятия {lesson.id}: {e}') # Оптимизация: используем bulk_update вместо цикла с save() if lessons_to_update: Lesson.objects.bulk_update(lessons_to_update, ['attendance_confirmation_sent'], batch_size=100) logger.info(f'[send_attendance_confirmation_requests] Отправлено {sent_count} запросов о присутствии') return f'Отправлено {sent_count} запросов' @shared_task def maintain_recurring_lessons(): """ Поддержание 12 будущих занятий для повторяющихся занятий. Задача проверяет все повторяющиеся занятия и добавляет недостающие, чтобы всегда было 12 будущих занятий впереди. Запускается каждый день через Celery Beat. """ now = timezone.now() added_count = 0 try: # Находим все уникальные серии повторяющихся занятий recurring_series = Lesson.objects.filter( is_recurring=True, recurring_series_id__isnull=False ).values_list('recurring_series_id', flat=True).distinct() for series_id in recurring_series: # Находим все занятия этой серии, которые еще не прошли series_lessons = Lesson.objects.filter( recurring_series_id=series_id, start_time__gt=now # Только будущие занятия ).order_by('start_time') if not series_lessons.exists(): # Если нет будущих занятий, пропускаем эту серию continue # Находим последнее занятие в серии (самое дальнее по времени) last_lesson = series_lessons.last() # Подсчитываем, сколько будущих занятий есть future_count = series_lessons.count() # Если меньше 12, добавляем недостающие if future_count < 12: # Находим первое занятие серии для получения шаблона first_lesson = Lesson.objects.filter( recurring_series_id=series_id, parent_lesson__isnull=True # Родительское занятие ).first() if not first_lesson: # Если нет родительского, берем первое занятие серии first_lesson = Lesson.objects.filter( recurring_series_id=series_id ).order_by('start_time').first() if not first_lesson: continue # Получаем время начала и окончания последнего занятия last_start_time = last_lesson.start_time last_end_time = last_lesson.end_time duration_minutes = last_lesson.duration # Вычисляем, сколько занятий нужно добавить lessons_to_add = 12 - future_count # Создаем недостающие занятия new_lessons = [] for i in range(1, lessons_to_add + 1): # Каждое следующее занятие через неделю после предыдущего new_start_time = last_start_time + timedelta(weeks=i) new_end_time = new_start_time + timedelta(minutes=duration_minutes) lesson_data = { 'mentor': first_lesson.mentor, 'client': first_lesson.client, 'group': first_lesson.group, 'start_time': new_start_time, 'end_time': new_end_time, 'duration': duration_minutes, 'title': first_lesson.title, 'description': first_lesson.description or '', 'subject': first_lesson.subject, 'mentor_subject': first_lesson.mentor_subject, 'subject_name': first_lesson.subject_name or (first_lesson.subject.name if first_lesson.subject else '') or (first_lesson.mentor_subject.name if first_lesson.mentor_subject else ''), 'template': first_lesson.template, 'price': first_lesson.price, 'is_recurring': True, 'recurring_series_id': series_id, 'parent_lesson': first_lesson if first_lesson.parent_lesson is None else first_lesson.parent_lesson, } new_lessons.append(Lesson(**lesson_data)) # Массовое создание для оптимизации if new_lessons: Lesson.objects.bulk_create(new_lessons) added_count += len(new_lessons) logger.info( f'Добавлено {len(new_lessons)} занятий для серии {series_id}. ' f'Теперь будущих занятий: {future_count + len(new_lessons)}' ) logger.info(f'[maintain_recurring_lessons] Добавлено {added_count} занятий для поддержания 12 будущих занятий') return f'Добавлено {added_count} занятий' except Exception as e: logger.error(f'[maintain_recurring_lessons] Ошибка: {str(e)}', exc_info=True) raise @shared_task def promote_mentor_subjects_to_subjects(): """ Переносит кастомные предметы менторов в общую модель Subject, если предмет используется более чем 10 менторами. Запускается периодически через Celery Beat (например, раз в день). """ promoted_count = 0 try: # Находим все уникальные названия кастомных предметов # и подсчитываем количество менторов, использующих каждый предмет from django.db.models import Count mentor_subjects_stats = MentorSubject.objects.values('name').annotate( mentor_count=Count('mentor', distinct=True) ).filter(mentor_count__gte=10) # Используется 10+ менторами for stat in mentor_subjects_stats: subject_name = stat['name'] mentor_count = stat['mentor_count'] # Проверяем, существует ли уже такой предмет в Subject existing_subject = Subject.objects.filter(name__iexact=subject_name).first() if existing_subject: # Если предмет уже существует, просто активируем его if not existing_subject.is_active: existing_subject.is_active = True existing_subject.save() logger.info(f'Активирован существующий предмет: {subject_name}') else: # Создаем новый предмет в Subject new_subject = Subject.objects.create( name=subject_name, is_active=True ) logger.info(f'Создан новый предмет в общей модели: {subject_name} (используется {mentor_count} менторами)') # Обновляем все занятия, использующие этот кастомный предмет # Заменяем mentor_subject на subject mentor_subjects = MentorSubject.objects.filter(name__iexact=subject_name) for mentor_subject in mentor_subjects: # Находим или создаем Subject subject = Subject.objects.filter(name__iexact=subject_name).first() if not subject: subject = Subject.objects.create(name=subject_name, is_active=True) # Обновляем занятия updated_lessons = Lesson.objects.filter(mentor_subject=mentor_subject).update( subject=subject, mentor_subject=None, subject_name=subject.name ) # Обновляем шаблоны from .models import LessonTemplate LessonTemplate.objects.filter(mentor_subject=mentor_subject).update( subject=subject, mentor_subject=None, subject_name=subject.name ) if updated_lessons > 0: logger.info( f'Обновлено {updated_lessons} занятий и шаблонов для предмета "{subject_name}" ' f'(ментор: {mentor_subject.mentor.get_full_name()})' ) # Удаляем кастомные предметы, которые были перенесены deleted_count = mentor_subjects.delete()[0] if deleted_count > 0: logger.info(f'Удалено {deleted_count} кастомных предметов "{subject_name}" после переноса в общую модель') promoted_count += deleted_count logger.info(f'[promote_mentor_subjects_to_subjects] Перенесено {promoted_count} кастомных предметов в общую модель') return f'Перенесено {promoted_count} предметов' except Exception as e: logger.error(f'[promote_mentor_subjects_to_subjects] Ошибка: {str(e)}', exc_info=True) raise @shared_task(name='apps.schedule.tasks.start_lessons_automatically') def start_lessons_automatically(): """ Автоматическое начало и завершение занятий по времени. Обновляет статус занятий: - 'scheduled' -> 'in_progress' когда наступает время начала (start_time <= now) - 'scheduled' или 'in_progress' -> 'completed' когда время окончания прошло (end_time < now) Запускается каждую минуту через Celery Beat. """ now = timezone.now() started_count = 0 completed_count = 0 logger.info(f'[start_lessons_automatically] Запуск') try: # Находим все запланированные занятия, которые должны начаться # start_time <= now (время начала уже наступило) # end_time >= now (время окончания еще не наступило) # status = 'scheduled' (еще не начались) lessons_to_start = Lesson.objects.filter( status='scheduled', start_time__lte=now, end_time__gte=now ).select_related('mentor', 'client') # Оптимизация: используем bulk_update вместо цикла с save() lessons_to_start_list = list(lessons_to_start) for lesson in lessons_to_start_list: lesson.status = 'in_progress' if lessons_to_start_list: Lesson.objects.bulk_update(lessons_to_start_list, ['status']) started_count = len(lessons_to_start_list) for lesson in lessons_to_start_list: logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"') # Находим занятия, которые уже прошли и должны быть завершены: # 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=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() for lesson in lessons_to_complete_list: lesson.status = 'completed' lesson.completed_at = now if lessons_to_complete_list: Lesson.objects.bulk_update(lessons_to_complete_list, ['status', 'completed_at']) completed_count = len(lessons_to_complete_list) for lesson in lessons_to_complete_list: logger.info(f'Занятие {lesson.id} автоматически переведено в статус "completed" (время окончания прошло)') # Закрываем LiveKit комнату, если она есть try: from apps.video.models import VideoRoom from apps.video.services import get_sfu_client, SFUClientError video_room = VideoRoom.objects.filter(lesson=lesson).first() if video_room and video_room.room_id: sfu_client = get_sfu_client() try: sfu_client.delete_room(str(video_room.room_id)) logger.info(f'LiveKit комната {video_room.room_id} закрыта для урока {lesson.id}') except SFUClientError as e: logger.warning(f'Не удалось закрыть LiveKit комнату {video_room.room_id} для урока {lesson.id}: {e}') except Exception as e: 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, only_if_someone_not_connected=True) except Exception as e: logger.warning(f'Не удалось отправить подтверждение занятия в Telegram: {e}') if started_count > 0 or completed_count > 0: logger.info(f'[start_lessons_automatically] Начато: {started_count}, Завершено: {completed_count}') return f'Начато {started_count}, Завершено {completed_count}' except Exception as e: logger.error(f'[start_lessons_automatically] Ошибка: {str(e)}', exc_info=True) raise