"""
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')
msg_student = f'Проверено ДЗ "{homework_title}". Оценка: {score}/5'
if feedback and str(feedback).strip():
comment = (feedback[:500] + '…') if len(feedback) > 500 else feedback
msg_student += f'\n\n💬 Комментарий:\n{comment}'
NotificationService.create_notification_with_telegram(
recipient=submission.student,
notification_type='homework_reviewed',
title='✅ ДЗ проверено',
message=msg_student,
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')
# Формируем сообщение с комментарием ИИ (HTML форматирование для Telegram)
feedback_preview = feedback[:400] + "..." if len(feedback) > 400 else feedback
# Экранируем HTML символы в комментарии
import html
feedback_escaped = html.escape(feedback_preview)
message_text = (
f'{student_name} — ДЗ «{homework_title}»\n\n'
f'🤖 Предварительная проверка ИИ:\n'
f'⭐ Оценка: {score}/5\n\n'
f'💬 Комментарий ИИ:\n{feedback_escaped}\n\n'
f'📝 Сохранено как черновик.\n\n'
f'В боте нажмите «Домашние задания» → выберите это задание — там кнопки «Редактировать ответ» и «Сохранить ответ».'
)
NotificationService.create_notification_with_telegram(
recipient=mentor,
notification_type='homework_submitted',
title='🤖 ИИ проверил ДЗ, статус: черновик',
message=message_text,
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} старых файлов"