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