702 lines
23 KiB
Python
702 lines
23 KiB
Python
"""
|
||
Модели для домашних заданий.
|
||
"""
|
||
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 |