""" Модели для домашних заданий. """ from django.db import models from django.utils import timezone from django.core.validators import MinValueValidator, MaxValueValidator import uuid import os def homework_file_upload_path(instance, filename): """Путь для загрузки файлов заданий.""" ext = filename.split('.')[-1] filename = f"{uuid.uuid4()}.{ext}" return os.path.join('homework', 'assignments', str(instance.id), filename) def submission_file_upload_path(instance, filename): """Путь для загрузки файлов решений.""" ext = filename.split('.')[-1] filename = f"{uuid.uuid4()}.{ext}" return os.path.join('homework', 'submissions', str(instance.id), filename) class Homework(models.Model): """ Модель домашнего задания. """ STATUS_CHOICES = [ ('draft', 'Черновик'), ('published', 'Опубликовано'), ('archived', 'В архиве'), ] # Основная информация title = models.CharField( max_length=255, verbose_name='Название' ) description = models.TextField( blank=True, default='', verbose_name='Описание задания' ) # Автор и связи mentor = models.ForeignKey( 'users.User', on_delete=models.CASCADE, related_name='created_homeworks', limit_choices_to={'role': 'mentor'}, verbose_name='Ментор' ) lesson = models.ForeignKey( 'schedule.Lesson', on_delete=models.SET_NULL, related_name='homeworks', null=True, blank=True, verbose_name='Занятие' ) # Кому назначено assigned_to = models.ManyToManyField( 'users.User', related_name='assigned_homeworks', blank=True, verbose_name='Назначено' ) # Файлы задания attachment = models.FileField( upload_to=homework_file_upload_path, blank=True, max_length=500, verbose_name='Файл задания' ) attachment_url = models.URLField( blank=True, max_length=500, verbose_name='Ссылка на материал' ) # Дедлайн deadline = models.DateTimeField( null=True, blank=True, verbose_name='Дедлайн', db_index=True ) # Баллы (по умолчанию шкала 1–5, проходной не учитывается) max_score = models.IntegerField( default=5, validators=[MinValueValidator(1), MaxValueValidator(100)], verbose_name='Максимальный балл' ) passing_score = models.IntegerField( default=1, validators=[MinValueValidator(0)], verbose_name='Проходной балл' ) # Настройки allow_late_submission = models.BooleanField( default=False, verbose_name='Разрешить сдачу после дедлайна' ) auto_check_enabled = models.BooleanField( default=False, verbose_name='Автоматическая проверка' ) ai_check_enabled = models.BooleanField( default=False, verbose_name='AI проверка' ) requires_file = models.BooleanField( default=True, verbose_name='Требуется файл' ) allowed_file_types = models.CharField( max_length=255, default='.pdf,.doc,.docx,.txt,.jpg,.png', verbose_name='Разрешенные типы файлов' ) max_file_size = models.IntegerField( default=10485760, # 10 MB verbose_name='Максимальный размер файла (bytes)' ) # Статус status = models.CharField( max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name='Статус', db_index=True ) # Черновик «заполнить позже» — создан при завершении урока, ментор должен дописать задание fill_later = models.BooleanField( default=False, verbose_name='Заполнить позже', db_index=True ) # Статистика total_submissions = models.IntegerField( default=0, verbose_name='Всего решений' ) checked_submissions = models.IntegerField( default=0, verbose_name='Проверено решений' ) returned_submissions = models.IntegerField( default=0, verbose_name='Возвращено на доработку' ) ai_draft_submissions = models.IntegerField( default=0, verbose_name='Черновиков от ИИ', help_text='Количество решений со статусом «ожидает проверки» и заполненным черновиком от ИИ' ) average_score = models.FloatField( default=0.0, verbose_name='Средний балл' ) # Временные метки created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата создания' ) updated_at = models.DateTimeField( auto_now=True, verbose_name='Дата обновления' ) published_at = models.DateTimeField( null=True, blank=True, verbose_name='Дата публикации' ) class Meta: db_table = 'homeworks' verbose_name = 'Домашнее задание' verbose_name_plural = 'Домашние задания' ordering = ['-created_at'] indexes = [ models.Index(fields=['mentor', 'status']), models.Index(fields=['lesson']), models.Index(fields=['deadline']), models.Index(fields=['status', 'published_at']), models.Index(fields=['mentor', 'created_at']), models.Index(fields=['status', 'deadline']), ] def __str__(self): return self.title def publish(self): """Опубликовать задание.""" if self.status != 'published': self.status = 'published' self.published_at = timezone.now() self.save() def archive(self): """Архивировать задание.""" self.status = 'archived' self.save() def is_overdue(self): """Проверка просрочено ли задание.""" if self.deadline: return timezone.now() > self.deadline return False def update_statistics(self): """Обновить статистику задания.""" submissions = self.submissions.all() self.total_submissions = submissions.count() self.checked_submissions = submissions.filter(status='graded').count() self.returned_submissions = submissions.filter(status='returned').count() self.ai_draft_submissions = submissions.filter( status='pending' ).exclude(ai_checked_at__isnull=True).count() graded = submissions.filter(status='graded') if graded.exists(): self.average_score = graded.aggregate( avg=models.Avg('score') )['avg'] or 0.0 else: self.average_score = 0.0 self.save(update_fields=[ 'total_submissions', 'checked_submissions', 'returned_submissions', 'ai_draft_submissions', 'average_score' ]) def assignment_file_upload_path(instance, filename): """Путь для загрузки файлов задания (одно назначение — только задание).""" ext = filename.split('.')[-1] if '.' in filename else '' name = f"{uuid.uuid4()}.{ext}" if ext else str(uuid.uuid4()) return os.path.join('homework', 'assignment_files', str(instance.homework_id), name) class HomeworkAssignmentFile(models.Model): """ Файл задания: прямая связь Homework → файл. Только для файлов, прикреплённых ментором к заданию (без file_type, без submission). """ homework = models.ForeignKey( Homework, on_delete=models.CASCADE, related_name='assignment_files', verbose_name='Домашнее задание', ) file = models.FileField( upload_to=assignment_file_upload_path, max_length=500, verbose_name='Файл', ) filename = models.CharField( max_length=255, verbose_name='Название файла', ) file_size = models.BigIntegerField( verbose_name='Размер файла (bytes)', ) uploaded_by = models.ForeignKey( 'users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='uploaded_assignment_files', verbose_name='Загрузил', ) created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата загрузки', ) class Meta: db_table = 'homework_assignment_files' verbose_name = 'Файл задания' verbose_name_plural = 'Файлы задания' ordering = ['created_at'] def __str__(self): return self.filename class HomeworkSubmission(models.Model): """ Модель решения домашнего задания. """ STATUS_CHOICES = [ ('pending', 'Ожидает проверки'), ('checking', 'На проверке'), ('graded', 'Проверено'), ('returned', 'Возвращено на доработку'), ] # Основная информация homework = models.ForeignKey( Homework, on_delete=models.CASCADE, related_name='submissions', verbose_name='Домашнее задание' ) student = models.ForeignKey( 'users.User', on_delete=models.CASCADE, related_name='homework_submissions', verbose_name='Студент' ) # Содержимое решения content = models.TextField( blank=True, verbose_name='Текст решения' ) attachment = models.FileField( upload_to=submission_file_upload_path, blank=True, max_length=500, verbose_name='Файл решения' ) attachment_url = models.URLField( blank=True, max_length=500, verbose_name='Ссылка на решение' ) # Статус и проверка status = models.CharField( max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='Статус', db_index=True ) score = models.IntegerField( null=True, blank=True, validators=[MinValueValidator(0)], verbose_name='Балл' ) passed = models.BooleanField( default=False, verbose_name='Сдано' ) # Отзыв ментора feedback = models.TextField( blank=True, verbose_name='Отзыв' ) checked_by = models.ForeignKey( 'users.User', on_delete=models.SET_NULL, related_name='checked_submissions', null=True, blank=True, verbose_name='Проверил' ) checked_at = models.DateTimeField( null=True, blank=True, verbose_name='Дата проверки' ) # AI проверка ai_score = models.IntegerField( null=True, blank=True, verbose_name='AI балл' ) ai_feedback = models.TextField( blank=True, verbose_name='AI отзыв' ) ai_checked_at = models.DateTimeField( null=True, blank=True, verbose_name='Дата AI проверки' ) graded_by_ai = models.BooleanField( default=False, verbose_name='Оценку выставил ИИ', help_text='True, если оценка опубликована автоматически через ИИ' ) # Попытки attempt_number = models.IntegerField( default=1, verbose_name='Номер попытки' ) is_late = models.BooleanField( default=False, verbose_name='Сдано с опозданием' ) # Временные метки submitted_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата отправки' ) updated_at = models.DateTimeField( auto_now=True, verbose_name='Дата обновления' ) class Meta: db_table = 'homework_submissions' verbose_name = 'Решение ДЗ' verbose_name_plural = 'Решения ДЗ' ordering = ['-submitted_at'] unique_together = ['homework', 'student', 'attempt_number'] indexes = [ models.Index(fields=['homework', 'student']), models.Index(fields=['student', 'status']), models.Index(fields=['status', 'submitted_at']), models.Index(fields=['homework', 'status']), models.Index(fields=['submitted_at']), ] def __str__(self): return f"{self.student.get_full_name()} - {self.homework.title}" def grade(self, score, feedback, checked_by): """Выставить оценку.""" self.status = 'graded' self.score = score self.feedback = feedback self.checked_by = checked_by self.checked_at = timezone.now() # Проверяем прошло ли if score >= self.homework.passing_score: self.passed = True self.save() # Обновляем статистику задания self.homework.update_statistics() def return_for_revision(self, feedback, checked_by): """Вернуть на доработку.""" self.status = 'returned' self.feedback = feedback self.checked_by = checked_by self.checked_at = timezone.now() self.save() # Обновляем статистику задания self.homework.update_statistics() def check_if_late(self): """Проверить сдано ли с опозданием.""" if self.homework.deadline: if self.submitted_at > self.homework.deadline: self.is_late = True self.save(update_fields=['is_late']) class HomeworkFile(models.Model): """ Дополнительные файлы к домашнему заданию или решению. """ FILE_TYPE_CHOICES = [ ('assignment', 'Файл задания'), ('submission', 'Файл решения'), ('feedback', 'Файл отзыва'), ] homework = models.ForeignKey( Homework, on_delete=models.CASCADE, related_name='files', null=True, blank=True, verbose_name='Домашнее задание' ) submission = models.ForeignKey( HomeworkSubmission, on_delete=models.CASCADE, related_name='files', null=True, blank=True, verbose_name='Решение' ) file_type = models.CharField( max_length=20, choices=FILE_TYPE_CHOICES, verbose_name='Тип файла' ) file = models.FileField( upload_to='homework/files/', max_length=500, verbose_name='Файл' ) filename = models.CharField( max_length=255, verbose_name='Название файла' ) file_size = models.BigIntegerField( verbose_name='Размер файла (bytes)' ) uploaded_by = models.ForeignKey( 'users.User', on_delete=models.SET_NULL, related_name='uploaded_homework_files', null=True, verbose_name='Загрузил' ) created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата загрузки' ) class Meta: db_table = 'homework_files' verbose_name = 'Файл ДЗ' verbose_name_plural = 'Файлы ДЗ' ordering = ['-created_at'] def __str__(self): return self.filename class HomeworkAIAgent(models.Model): """ ИИ-агент для проверки домашних заданий. OpenAI-совместимый API. Рекомендуется RouterAI: https://routerai.ru/docs/reference Эндпоинт: POST {base_url}/chat/completions (OpenAI-формат). В openai_url храним базовый URL до .../v1 (без /chat/completions). """ name = models.CharField( max_length=255, verbose_name='Название модели' ) openai_url = models.URLField( max_length=500, verbose_name='OpenAI URL (базовый)', help_text='Базовый URL до .../v1. RouterAI: https://routerai.ru/api/v1' ) model_name = models.CharField( max_length=255, verbose_name='Название модели', help_text='Идентификатор модели RouterAI, например: google/gemini-3-flash-preview, openai/gpt-4o-mini, anthropic/claude-3-5-sonnet. Список: https://routerai.ru/models' ) api_key = models.CharField( max_length=2048, blank=True, verbose_name='API ключ (токен)', help_text='API-ключ с https://routerai.ru/settings/keys. Пусто — использовать HOMEWORK_AI_API_KEY из .env' ) AUTH_HEADER_BEARER = 'Bearer' AUTH_HEADER_X_API_KEY = 'X-API-Key' AUTH_HEADER_CHOICES = [ (AUTH_HEADER_BEARER, 'Authorization: Bearer (по умолчанию, RouterAI)'), (AUTH_HEADER_X_API_KEY, 'X-API-Key'), ] auth_header = models.CharField( max_length=32, choices=AUTH_HEADER_CHOICES, default=AUTH_HEADER_BEARER, verbose_name='Заголовок авторизации', help_text='RouterAI использует Bearer. При 401 у другого провайдера попробуйте X-API-Key.' ) system_prompt = models.TextField( blank=True, default='', verbose_name='Системный промпт', help_text='Системный промпт для модели (роль и инструкции проверки). Пусто — используется встроенный промпт проверки ДЗ.' ) is_default = models.BooleanField( default=False, verbose_name='Использовать по умолчанию для проверки ДЗ' ) order = models.PositiveIntegerField( default=0, verbose_name='Порядок сортировки' ) is_active = models.BooleanField( default=True, verbose_name='Активен' ) dev_mode = models.BooleanField( default=False, verbose_name='Режим разработки (AI)', help_text='Включить отладочный промпт: ИИ описывает содержимое изображений в комментарии. Выключено — обычная проверка.' ) # Параметры генерации, влияющие на ответ модели (OpenAI/RouterAI chat completions) temperature = models.FloatField( null=True, blank=True, validators=[MinValueValidator(0.0), MaxValueValidator(2.0)], verbose_name='Temperature', help_text='Случайность ответа: 0 = детерминированный, 2 = максимально разнообразный. Обычно 0.3–0.7 для проверки ДЗ. Пусто — значение по умолчанию провайдера.' ) top_p = models.FloatField( null=True, blank=True, validators=[MinValueValidator(0.0), MaxValueValidator(1.0)], verbose_name='Top P (nucleus sampling)', help_text='Доля наиболее вероятных токенов для выбора (0–1). Меньше — более фокусный ответ. Пусто — по умолчанию провайдера.' ) max_tokens = models.PositiveIntegerField( null=True, blank=True, verbose_name='Max output tokens', help_text='Максимальная длина ответа в токенах. Пусто — лимит провайдера. Для развёрнутого комментария можно 2000–4000.' ) usage_count = models.PositiveIntegerField( default=0, verbose_name='Использований', help_text='Счётчик успешных проверок ДЗ через этого агента.' ) total_prompt_tokens = models.PositiveBigIntegerField( default=0, verbose_name='Всего токенов (вход)', help_text='Накоплено входящих токенов за все проверки. Баланс и лимиты — в личном кабинете RouterAI.' ) total_completion_tokens = models.PositiveBigIntegerField( default=0, verbose_name='Всего токенов (выход)', help_text='Накоплено исходящих токенов за все проверки.' ) created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создан') updated_at = models.DateTimeField(auto_now=True, verbose_name='Обновлён') class Meta: db_table = 'homework_ai_agents' verbose_name = 'ИИ-агент для ДЗ' verbose_name_plural = 'ИИ-агенты для ДЗ' ordering = ['order', 'name'] def __str__(self): return f'{self.name} ({self.model_name})' def save(self, *args, **kwargs): # Нормализация: убираем /chat/completions, если вставили полный URL из документации if self.openai_url: url = self.openai_url.rstrip('/') if url.endswith('/chat/completions'): self.openai_url = url[:-len('/chat/completions')].rstrip('/') super().save(*args, **kwargs) def get_base_url(self): """Базовый URL для OpenAI-клиента (без завершающего слэша).""" if not self.openai_url: return '' url = self.openai_url.rstrip('/') if url.endswith('/chat/completions'): url = url[:-len('/chat/completions')].rstrip('/') return url