uchill/backend/apps/homework/tasks.py

470 lines
22 KiB
Python
Raw 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 задачи для домашних заданий.
"""
from celery import shared_task
from django.utils import timezone
from datetime import timedelta
import logging
logger = logging.getLogger(__name__)
def _student_display_name(submission):
"""Имя ученика (только имя, без фамилии) — тот, кто отправил ДЗ."""
if not submission or not getattr(submission, 'student', None):
return None
s = submission.student
name = (s.first_name or (s.get_short_name() if getattr(s, 'email', None) else '') or '').strip()
return name or f"Студент (id: {s.pk})"
@shared_task
def send_homework_deadline_reminders():
"""
Отправка напоминаний о приближающихся дедлайнах домашних заданий.
Запускается каждый день в 09:00.
Отправляет напоминания за 1 день, 3 дня и 1 неделю до дедлайна.
"""
from .models import Homework
from apps.notifications.services import NotificationService
now = timezone.now()
# Находим задания с дедлайнами в ближайшие периоды
deadlines_to_check = [
(now + timedelta(days=1), 'Через 1 день'), # Завтра
(now + timedelta(days=3), 'Через 3 дня'), # Через 3 дня
(now + timedelta(days=7), 'Через неделю'), # Через неделю
]
sent_count = 0
for deadline_date, deadline_text in deadlines_to_check:
# Находим задания с дедлайном в этот день (в пределах дня)
start_of_day = deadline_date.replace(hour=0, minute=0, second=0, microsecond=0)
end_of_day = deadline_date.replace(hour=23, minute=59, second=59, microsecond=999999)
homeworks = Homework.objects.filter(
status='published',
deadline__gte=start_of_day,
deadline__lte=end_of_day
).select_related('mentor', 'lesson').prefetch_related('assigned_to', 'submissions')
for homework in homeworks:
# Оптимизация: используем prefetch_related для submissions и assigned_to
# Преобразуем в list для использования предзагруженных данных
submissions_list = list(homework.submissions.all())
assigned_students_list = list(homework.assigned_to.all())
submitted_student_ids = {sub.student_id for sub in submissions_list}
students_without_submission = [
student for student in assigned_students_list
if student.id not in submitted_student_ids
]
for student in students_without_submission:
try:
NotificationService.create_notification_with_telegram(
recipient=student,
notification_type='homework_deadline_reminder',
title='⏰ Напоминание о дедлайне',
message=f'Домашнее задание "{homework.title}" нужно сдать {deadline_text}',
priority='normal',
action_url=f'/homework/{homework.id}/',
content_object=homework
)
sent_count += 1
except Exception as e:
logger.error(f"Ошибка отправки напоминания о дедлайне: {str(e)}")
logger.info(f"Отправлено {sent_count} напоминаний о дедлайнах домашних заданий")
return f"Отправлено {sent_count} напоминаний о дедлайнах"
@shared_task
def auto_check_homework_submissions():
"""
Автоматическая проверка домашних заданий через AI (если включена).
Запускается каждые 30 минут.
Проверяет решения, у которых включена автоматическая проверка.
"""
from .models import HomeworkSubmission
from .ai_service import get_ai_service
# Находим решения с включенной автоматической проверкой
submissions = HomeworkSubmission.objects.filter(
homework__auto_check_enabled=True,
homework__ai_check_enabled=True,
status='pending',
ai_checked_at__isnull=True
).select_related('homework', 'student')[:10] # Ограничиваем количество за раз
checked_count = 0
for submission in submissions:
try:
# Получаем файлы решения
submission_files = []
if submission.attachment:
submission_files.append(submission.attachment.name)
from .models import HomeworkFile
# Оптимизация: используем values_list для получения только имен файлов
additional_file_names = HomeworkFile.objects.filter(
submission=submission,
file_type='submission'
).exclude(file='').values_list('file', flat=True)
submission_files.extend([name for name in additional_file_names if name])
# Вызываем AI проверку
ai_service = get_ai_service()
result = ai_service.check_submission(
homework_title=submission.homework.title,
homework_description=submission.homework.description,
homework_max_score=submission.homework.max_score,
submission_content=submission.content or '',
submission_files=submission_files,
student_name=_student_display_name(submission),
)
if result.get('success'):
# Сохраняем результат AI проверки
submission.ai_score = result.get('score')
submission.ai_feedback = result.get('feedback')
submission.ai_checked_at = timezone.now()
submission.status = 'checking' # Переводим в статус "на проверке"
submission.save(update_fields=['ai_score', 'ai_feedback', 'ai_checked_at', 'status'])
checked_count += 1
logger.info(f"Автоматически проверено решение ДЗ {submission.id} через AI. Оценка: {submission.ai_score}")
except Exception as e:
logger.error(f"Ошибка автоматической проверки решения ДЗ {submission.id}: {str(e)}", exc_info=True)
if checked_count > 0:
logger.info(f"Автоматически проверено {checked_count} решений домашних заданий")
return f"Автоматически проверено {checked_count} решений"
@shared_task(bind=True)
def run_mentor_ai_check_submission(self, submission_id, publish):
"""
Проверка решения ДЗ через AI по настройкам ментора (доверять AI).
Вызывается после загрузки ДЗ студентом, если у ментора включено ai_trust_draft или ai_trust_publish.
publish: True — выставить оценку и опубликовать (status=graded, уведомление студенту);
False — сохранить только как черновик (ai_score, ai_feedback, ai_checked_at).
"""
import traceback
from .models import HomeworkSubmission, HomeworkFile
from .ai_service import get_ai_service
from django.core.files.storage import default_storage
from apps.users.cache_utils import invalidate_dashboard_cache
from apps.notifications.services import NotificationService
logger.info("run_mentor_ai_check_submission: старт submission_id=%s publish=%s", submission_id, publish)
try:
submission = HomeworkSubmission.objects.select_related(
'homework', 'homework__mentor', 'student'
).prefetch_related(
'homework__assignment_files'
).get(pk=submission_id)
except HomeworkSubmission.DoesNotExist:
logger.warning("run_mentor_ai_check_submission: submission %s не найден", submission_id)
return
try:
mentor = submission.homework.mentor
homework_files = []
homework_file_paths = []
homework_file_contents = []
def _add_homework_file(name):
homework_files.append(name)
try:
p = default_storage.path(name)
if p:
homework_file_paths.append(p)
return
except Exception:
pass
try:
with default_storage.open(name, 'rb') as f:
data = f.read(2 * 1024 * 1024 + 1)
fname = name.split('/')[-1] if '/' in name else name
homework_file_contents.append((fname, data[: 2 * 1024 * 1024]))
except Exception:
pass
if submission.homework.attachment:
_add_homework_file(submission.homework.attachment.name)
for f in submission.homework.assignment_files.all():
if f.file:
_add_homework_file(f.file.name)
submission_files = []
submission_file_paths = []
submission_file_contents = []
def _add_submission_file(name):
submission_files.append(name)
try:
p = default_storage.path(name)
if p:
submission_file_paths.append(p)
return
except Exception:
pass
try:
with default_storage.open(name, 'rb') as f:
data = f.read(2 * 1024 * 1024 + 1)
fname = name.split('/')[-1] if '/' in name else name
submission_file_contents.append((fname, data[: 2 * 1024 * 1024]))
except Exception:
pass
if submission.attachment:
_add_submission_file(submission.attachment.name)
for file_obj in HomeworkFile.objects.filter(submission=submission, file_type='submission'):
if file_obj.file:
_add_submission_file(file_obj.file.name)
ai_service = get_ai_service()
result = ai_service.check_submission(
homework_title=submission.homework.title,
homework_description=submission.homework.description or '',
homework_max_score=5,
submission_content=submission.content or '',
submission_files=submission_files,
homework_files=homework_files,
homework_file_paths=homework_file_paths,
submission_file_paths=submission_file_paths,
homework_file_contents=homework_file_contents,
submission_file_contents=submission_file_contents,
student_name=_student_display_name(submission),
)
if not result.get('success'):
logger.warning(
"run_mentor_ai_check_submission: AI проверка не удалась для submission %s: %s",
submission_id, result.get('error', '')
)
return
student_name = _student_display_name(submission)
homework_title = submission.homework.title
skipped_reason = result.get('skipped_reason')
if skipped_reason:
# Текст не удалось извлечь — сохраняем черновик, запрос к AI не отправляли
submission.ai_score = None
submission.ai_feedback = result.get('feedback', '')
submission.ai_checked_at = timezone.now()
submission.save(update_fields=['ai_score', 'ai_feedback', 'ai_checked_at'])
invalidate_dashboard_cache(mentor.id, 'mentor')
NotificationService.create_notification_with_telegram(
recipient=mentor,
notification_type='homework_submitted',
title='⚠️ ИИ не смог прочитать задание или решение',
message=f'{student_name} — ДЗ «{homework_title}»: не удалось извлечь текст из файлов. Проверьте вручную или добавьте текст/.txt.',
priority='normal',
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
content_object=submission
)
logger.info("run_mentor_ai_check_submission: submission %s — пропуск AI (skipped_reason=%s)", submission_id, skipped_reason)
return
score = result.get('score')
feedback = result.get('feedback', '')
score = max(1, min(5, score)) if score is not None else None
if score is None:
logger.warning("run_mentor_ai_check_submission: submission %s — AI вернул пустую оценку", submission_id)
return
if publish:
submission.grade(score, feedback, checked_by=mentor)
submission.graded_by_ai = True
submission.save(update_fields=['graded_by_ai'])
invalidate_dashboard_cache(submission.student.id, 'client')
invalidate_dashboard_cache(mentor.id, 'mentor')
NotificationService.create_notification_with_telegram(
recipient=submission.student,
notification_type='homework_reviewed',
title='✅ ДЗ проверено',
message=f'Проверено ДЗ "{homework_title}". Оценка: {score}/5',
priority='normal',
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
content_object=submission
)
NotificationService.create_notification_with_telegram(
recipient=mentor,
notification_type='homework_reviewed',
title='🤖 ИИ проверил ДЗ и выставил оценку',
message=f'{student_name} — ДЗ «{homework_title}»: оценка {score}/5. ИИ поставил оценку и опубликовал результат.',
priority='normal',
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
content_object=submission
)
logger.info("run_mentor_ai_check_submission: submission %s опубликована AI, оценка %s", submission_id, score)
else:
submission.ai_score = score
submission.ai_feedback = feedback
submission.ai_checked_at = timezone.now()
submission.save(update_fields=['ai_score', 'ai_feedback', 'ai_checked_at'])
invalidate_dashboard_cache(mentor.id, 'mentor')
NotificationService.create_notification_with_telegram(
recipient=mentor,
notification_type='homework_submitted',
title='🤖 ИИ проверил ДЗ, статус: черновик',
message=f'{student_name} — ДЗ «{homework_title}»: предварительная оценка {score}/5. ИИ сохранил как черновик — можете отредактировать и опубликовать.',
priority='normal',
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
content_object=submission
)
logger.info("run_mentor_ai_check_submission: submission %s сохранена как черновик AI, оценка %s", submission_id, score)
except Exception as e:
logger.exception(
"run_mentor_ai_check_submission: ошибка для submission_id=%s: %s\n%s",
submission_id, e, traceback.format_exc()
)
raise
@shared_task
def cleanup_old_homework_data():
"""
Очистка старых данных домашних заданий.
Запускается каждый месяц 1-го числа в 05:00.
Архивирует старые задания и удаляет старые файлы.
"""
from .models import Homework
from django.utils import timezone
from datetime import timedelta
# Архивируем задания старше 1 года без активности
one_year_ago = timezone.now() - timedelta(days=365)
old_homeworks = Homework.objects.filter(
status='published',
updated_at__lt=one_year_ago,
submissions__isnull=True # Без решений
)
archived_count = old_homeworks.update(status='archived')
logger.info(f"Архивировано {archived_count} старых домашних заданий")
return f"Архивировано {archived_count} старых домашних заданий"
@shared_task
def update_homework_statistics():
"""
Обновление статистики домашних заданий.
Запускается каждый день в 02:00.
Пересчитывает статистику для всех активных заданий.
"""
from .models import Homework
# Находим все опубликованные задания
homeworks = Homework.objects.filter(status='published')
updated_count = 0
for homework in homeworks:
try:
homework.update_statistics()
updated_count += 1
except Exception as e:
logger.error(f"Ошибка обновления статистики для ДЗ {homework.id}: {str(e)}")
logger.info(f"Обновлена статистика для {updated_count} домашних заданий")
return f"Обновлена статистика для {updated_count} домашних заданий"
@shared_task
def check_overdue_homeworks():
"""
Проверка просроченных домашних заданий и отправка уведомлений.
Запускается каждый день в 08:00.
Отправляет уведомления студентам о просроченных заданиях.
"""
from .models import Homework
from apps.notifications.services import NotificationService
now = timezone.now()
# Находим просроченные опубликованные задания
overdue_homeworks = Homework.objects.filter(
status='published',
deadline__lt=now
).select_related('mentor', 'lesson').prefetch_related('assigned_to', 'submissions')
sent_count = 0
for homework in overdue_homeworks:
# Оптимизация: используем prefetch_related для submissions
submitted_student_ids = {sub.student_id for sub in homework.submissions.all()}
students_without_submission = [
student for student in homework.assigned_to.all()
if student.id not in submitted_student_ids
]
for student in students_without_submission:
try:
NotificationService.create_notification_with_telegram(
recipient=student,
notification_type='homework_overdue',
title='⚠️ Просрочено домашнее задание',
message=f'Просрочено домашнее задание "{homework.title}". Дедлайн: {homework.deadline.strftime("%d.%m.%Y %H:%M")}',
priority='high',
action_url=f'/homework/{homework.id}/',
content_object=homework
)
sent_count += 1
except Exception as e:
logger.error(f"Ошибка отправки уведомления о просроченном ДЗ: {str(e)}")
logger.info(f"Отправлено {sent_count} уведомлений о просроченных домашних заданиях")
return f"Отправлено {sent_count} уведомлений о просроченных заданиях"
@shared_task
def cleanup_old_files():
"""
Очистка старых неиспользуемых файлов домашних заданий.
Запускается каждую неделю в воскресенье в 02:00.
Удаляет файлы из архивированных заданий старше 2 лет.
"""
from .models import HomeworkFile
from django.utils import timezone
from datetime import timedelta
import os
two_years_ago = timezone.now() - timedelta(days=730)
# Находим файлы из архивированных заданий старше 2 лет
old_files = HomeworkFile.objects.filter(
homework__status='archived',
homework__updated_at__lt=two_years_ago,
created_at__lt=two_years_ago
)
deleted_count = 0
# Оптимизация: сначала удаляем физические файлы, затем удаляем записи одним запросом
old_files_list = list(old_files)
file_ids_to_delete = []
for file_obj in old_files_list:
try:
# Удаляем физический файл
if file_obj.file and os.path.isfile(file_obj.file.path):
os.remove(file_obj.file.path)
file_ids_to_delete.append(file_obj.id)
except Exception as e:
logger.error(f"Ошибка удаления физического файла ДЗ {file_obj.id}: {str(e)}")
# Удаляем записи из базы данных одним запросом
if file_ids_to_delete:
deleted_count = HomeworkFile.objects.filter(id__in=file_ids_to_delete).delete()[0]
logger.info(f"Удалено {deleted_count} старых файлов домашних заданий")
return f"Удалено {deleted_count} старых файлов"