uchill/backend/apps/schedule/tasks.py

463 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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