470 lines
22 KiB
Python
470 lines
22 KiB
Python
"""
|
||
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} старых файлов"
|