uchill/backend/apps/homework/models.py

702 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Модели для домашних заданий.
"""
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
)
# Баллы (по умолчанию шкала 15, проходной не учитывается)
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.30.7 для проверки ДЗ. Пусто — значение по умолчанию провайдера.'
)
top_p = models.FloatField(
null=True,
blank=True,
validators=[MinValueValidator(0.0), MaxValueValidator(1.0)],
verbose_name='Top P (nucleus sampling)',
help_text='Доля наиболее вероятных токенов для выбора (01). Меньше — более фокусный ответ. Пусто — по умолчанию провайдера.'
)
max_tokens = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name='Max output tokens',
help_text='Максимальная длина ответа в токенах. Пусто — лимит провайдера. Для развёрнутого комментария можно 20004000.'
)
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