463 lines
23 KiB
Python
463 lines
23 KiB
Python
# 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
|