uchill/backend/apps/homework/serializers.py

614 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 rest_framework import serializers
from django.utils import timezone
from .models import Homework, HomeworkSubmission, HomeworkFile, HomeworkAssignmentFile, HomeworkAIAgent
from apps.users.serializers import UserSerializer
from apps.users.mixins import TimezoneAwareSerializerMixin
class HomeworkAIAgentSerializer(serializers.ModelSerializer):
"""Сериализатор ИИ-агента (без api_key)."""
class Meta:
model = HomeworkAIAgent
fields = ['id', 'name', 'openai_url', 'model_name', 'is_default', 'order', 'is_active']
read_only_fields = ['id', 'name', 'openai_url', 'model_name', 'is_default', 'order', 'is_active']
IMAGE_EXTENSIONS = frozenset(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'])
def _is_image_filename(filename):
"""Определение по расширению, что файл — изображение (для превью и модального просмотра)."""
if not filename or '.' not in str(filename).strip():
return False
ext = str(filename).rsplit('.', 1)[-1].lower()
return ext in IMAGE_EXTENSIONS
class HomeworkFileSerializer(serializers.ModelSerializer):
"""Сериализатор файла ДЗ (решения/отзывы)."""
uploaded_by = UserSerializer(read_only=True)
is_image = serializers.SerializerMethodField()
def get_is_image(self, obj):
if _is_image_filename(obj.filename):
return True
if getattr(obj, 'file', None) and obj.file.name:
return _is_image_filename(obj.file.name)
return False
class Meta:
model = HomeworkFile
fields = [
'id',
'file_type',
'file',
'filename',
'file_size',
'uploaded_by',
'created_at',
'is_image',
]
read_only_fields = ['uploaded_by', 'created_at']
class HomeworkAssignmentFileSerializer(serializers.ModelSerializer):
"""Сериализатор файла задания (прямая связь Homework → файл)."""
uploaded_by = UserSerializer(read_only=True)
file_type = serializers.ReadOnlyField(default='assignment')
is_image = serializers.SerializerMethodField()
def get_is_image(self, obj):
if _is_image_filename(obj.filename):
return True
if getattr(obj, 'file', None) and obj.file.name:
return _is_image_filename(obj.file.name)
return False
class Meta:
model = HomeworkAssignmentFile
fields = [
'id',
'file_type',
'file',
'filename',
'file_size',
'uploaded_by',
'created_at',
'is_image',
]
read_only_fields = ['uploaded_by', 'created_at']
class HomeworkSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer):
"""Сериализатор домашнего задания."""
mentor = UserSerializer(read_only=True)
assigned_to = UserSerializer(many=True, read_only=True)
files = serializers.SerializerMethodField()
is_overdue = serializers.BooleanField(read_only=True)
def get_files(self, obj):
"""Файлы задания — из модели HomeworkAssignmentFile (прямая связь)."""
if not hasattr(obj, 'assignment_files'):
return []
return HomeworkAssignmentFileSerializer(obj.assignment_files.all(), many=True).data
class Meta:
model = Homework
fields = [
'id',
'title',
'description',
'mentor',
'lesson',
'assigned_to',
'attachment',
'attachment_url',
'deadline',
'max_score',
'passing_score',
'allow_late_submission',
'auto_check_enabled',
'ai_check_enabled',
'requires_file',
'allowed_file_types',
'max_file_size',
'status',
'fill_later',
'total_submissions',
'checked_submissions',
'returned_submissions',
'average_score',
'is_overdue',
'files',
'created_at',
'updated_at',
'published_at'
]
read_only_fields = [
'mentor',
'total_submissions',
'checked_submissions',
'returned_submissions',
'average_score',
'created_at',
'updated_at',
'published_at'
]
timezone_aware_fields = ['deadline', 'created_at', 'updated_at', 'published_at']
class HomeworkListSerializer(serializers.ModelSerializer):
"""Сериализатор списка ДЗ (упрощенный)."""
mentor = UserSerializer(read_only=True)
is_overdue = serializers.BooleanField(read_only=True)
students = serializers.SerializerMethodField()
student_score = serializers.SerializerMethodField()
lesson_subject = serializers.SerializerMethodField()
ai_draft_count = serializers.SerializerMethodField()
class Meta:
model = Homework
fields = [
'id',
'title',
'mentor',
'lesson',
'lesson_subject',
'deadline',
'max_score',
'passing_score',
'status',
'fill_later',
'total_submissions',
'checked_submissions',
'returned_submissions',
'average_score',
'is_overdue',
'created_at',
'published_at',
'students',
'student_score',
'ai_draft_count',
]
def get_lesson_subject(self, obj):
"""Получить название предмета из урока."""
if obj.lesson and obj.lesson.subject:
return obj.lesson.subject.name
if obj.lesson and obj.lesson.mentor_subject:
return obj.lesson.mentor_subject.name
if obj.lesson and obj.lesson.subject_name:
return obj.lesson.subject_name
return None
def get_students(self, obj):
"""Получить список студентов для ментора."""
request = self.context.get('request')
if not request or request.user.role != 'mentor':
return None
# Получаем всех назначенных студентов
assigned_students = list(obj.assigned_to.all())
# Оптимизация: получаем все submissions одним запросом и группируем в Python
# Используем prefetch_related из views, но все равно нужно получить последнюю для каждого студента
all_submissions = list(obj.submissions.all().order_by('-submitted_at'))
# Группируем submissions по student_id, берем первую (последнюю по submitted_at)
submissions_by_student = {}
for submission in all_submissions:
student_id = submission.student_id
if student_id not in submissions_by_student:
submissions_by_student[student_id] = submission
students = []
for student in assigned_students:
submission = submissions_by_student.get(student.id)
if submission:
students.append({
'id': student.id,
'first_name': student.first_name,
'last_name': student.last_name,
'score': submission.score,
'status': submission.status,
})
else:
# Если решения нет, все равно показываем студента
students.append({
'id': student.id,
'first_name': student.first_name,
'last_name': student.last_name,
'score': None,
'status': None,
})
return students if students else None
def get_student_score(self, obj):
"""Получить оценку студента."""
request = self.context.get('request')
if not request or request.user.role != 'client':
return None
# Получаем последнюю попытку студента (может быть несколько попыток)
submission = obj.submissions.filter(student=request.user).order_by('-submitted_at').first()
if submission:
return {
'score': submission.score,
'max_score': obj.max_score,
'status': submission.status,
}
return None
def get_ai_draft_count(self, obj):
"""Количество решений с черновиком от ИИ. Только для ментора. Берём из поля модели ai_draft_submissions."""
request = self.context.get('request')
if not request or request.user.role != 'mentor':
return 0
return getattr(obj, 'ai_draft_submissions', 0)
class HomeworkCreateSerializer(serializers.ModelSerializer):
"""Сериализатор создания ДЗ."""
lesson_id = serializers.IntegerField(required=False, allow_null=True)
assigned_to_ids = serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_empty=True
)
class Meta:
model = Homework
extra_kwargs = {
'description': {'required': False, 'allow_blank': True},
}
fields = [
'title',
'description',
'lesson_id',
'assigned_to_ids',
'attachment',
'attachment_url',
'deadline',
'max_score',
'passing_score',
'allow_late_submission',
'auto_check_enabled',
'ai_check_enabled',
'requires_file',
'allowed_file_types',
'max_file_size',
'status',
'fill_later'
]
def validate_lesson_id(self, value):
"""Проверка занятия."""
if value:
from apps.schedule.models import Lesson
try:
lesson = Lesson.objects.get(id=value)
# Проверяем что пользователь - ментор занятия
user = self.context['request'].user
if lesson.mentor != user:
raise serializers.ValidationError(
'Вы не являетесь ментором этого занятия'
)
except Lesson.DoesNotExist:
raise serializers.ValidationError('Занятие не найдено')
return value
def create(self, validated_data):
"""Создание ДЗ."""
from apps.schedule.models import Lesson
from apps.users.models import User
lesson_id = validated_data.pop('lesson_id', None)
assigned_to_ids = validated_data.pop('assigned_to_ids', [])
user = self.context['request'].user
homework = Homework.objects.create(
mentor=user,
lesson_id=lesson_id,
**validated_data
)
# Добавляем студентов
if assigned_to_ids:
students = User.objects.filter(id__in=assigned_to_ids)
homework.assigned_to.set(students)
# Если задание для занятия, автоматически назначаем клиента
if lesson_id:
lesson = Lesson.objects.get(id=lesson_id)
if lesson.client and lesson.client.user:
homework.assigned_to.add(lesson.client.user)
return homework
def update(self, instance, validated_data):
"""Обновление ДЗ."""
from apps.schedule.models import Lesson
from apps.users.models import User
lesson_id = validated_data.pop('lesson_id', None)
assigned_to_ids = validated_data.pop('assigned_to_ids', None)
# Обновляем основные поля
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
# Обновляем связь с уроком
if lesson_id is not None:
instance.lesson_id = lesson_id
instance.save()
# Обновляем назначенных студентов
if assigned_to_ids is not None:
students = User.objects.filter(id__in=assigned_to_ids)
instance.assigned_to.set(students)
# Если задание для занятия, автоматически добавляем клиента
if lesson_id:
lesson = Lesson.objects.get(id=lesson_id)
if lesson.client and lesson.client.user:
instance.assigned_to.add(lesson.client.user)
return instance
class HomeworkSubmissionSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer):
"""Сериализатор решения ДЗ."""
student = UserSerializer(read_only=True)
checked_by = UserSerializer(read_only=True)
homework = HomeworkListSerializer(read_only=True)
files = HomeworkFileSerializer(many=True, read_only=True)
ai_feedback_html = serializers.SerializerMethodField()
feedback_html = serializers.SerializerMethodField()
class Meta:
model = HomeworkSubmission
fields = [
'id',
'homework',
'student',
'content',
'attachment',
'attachment_url',
'status',
'score',
'passed',
'feedback',
'feedback_html',
'checked_by',
'checked_at',
'ai_score',
'ai_feedback',
'ai_feedback_html',
'ai_checked_at',
'graded_by_ai',
'attempt_number',
'is_late',
'files',
'submitted_at',
'updated_at'
]
read_only_fields = [
'student',
'checked_by',
'checked_at',
'ai_score',
'ai_feedback',
'ai_checked_at',
'graded_by_ai',
'attempt_number',
'is_late',
'submitted_at',
'updated_at'
]
timezone_aware_fields = ['checked_at', 'ai_checked_at', 'submitted_at', 'updated_at']
def get_ai_feedback_html(self, obj):
"""HTML для отображения ai_feedback: markdown + LaTeX ($a$, $$...$$) → HTML/MathML."""
from .utils import feedback_to_html
return feedback_to_html(obj.ai_feedback) if obj.ai_feedback else ''
def get_feedback_html(self, obj):
"""HTML для отображения комментария проверки: markdown + LaTeX → HTML/MathML."""
from .utils import feedback_to_html
return feedback_to_html(obj.feedback) if obj.feedback else ''
class HomeworkSubmissionCreateSerializer(serializers.ModelSerializer):
"""Сериализатор создания решения ДЗ."""
homework_id = serializers.IntegerField(write_only=True)
class Meta:
model = HomeworkSubmission
fields = [
'homework_id',
'content',
'attachment',
'attachment_url'
]
def validate_homework_id(self, value):
"""Проверка ДЗ."""
try:
homework = Homework.objects.get(id=value)
except Homework.DoesNotExist:
raise serializers.ValidationError('Домашнее задание не найдено')
# Проверяем что задание опубликовано
if homework.status != 'published':
raise serializers.ValidationError('Задание еще не опубликовано')
# Проверяем дедлайн если не разрешена поздняя сдача
if not homework.allow_late_submission and homework.is_overdue():
raise serializers.ValidationError('Дедлайн прошел')
# Проверяем что пользователь назначен на это задание
user = self.context['request'].user
if not homework.assigned_to.filter(id=user.id).exists():
raise serializers.ValidationError('Вам не назначено это задание')
return value
def validate(self, attrs):
"""Общая валидация."""
homework = Homework.objects.get(id=attrs['homework_id'])
# Проверяем, что есть либо текст, либо файл/ссылка
has_content = bool(attrs.get('content', '').strip())
has_attachment = bool(attrs.get('attachment')) or bool(attrs.get('attachment_url', '').strip())
if not has_content and not has_attachment:
raise serializers.ValidationError({
'content': 'Необходимо указать текст ответа или прикрепить файл'
})
# Если требуется файл, проверяем его наличие
# Но если есть текстовый ответ, файл не обязателен
if homework.requires_file and not has_attachment and not has_content:
raise serializers.ValidationError({
'attachment': 'Требуется прикрепить файл или указать текстовый ответ'
})
return attrs
def create(self, validated_data):
"""Создание или обновление решения."""
homework_id = validated_data.pop('homework_id')
homework = Homework.objects.get(id=homework_id)
user = self.context['request'].user
# Проверяем, есть ли submission со статусом 'returned' для этого студента и задания
returned_submission = HomeworkSubmission.objects.filter(
homework=homework,
student=user,
status='returned'
).order_by('-submitted_at').first()
if returned_submission:
# Обновляем существующее submission, которое было возвращено на доработку
# Перезаписываем содержимое, но сохраняем тот же ID и attempt_number
if 'content' in validated_data:
returned_submission.content = validated_data['content']
if 'attachment' in validated_data and validated_data['attachment']:
returned_submission.attachment = validated_data['attachment']
if 'attachment_url' in validated_data:
returned_submission.attachment_url = validated_data['attachment_url']
returned_submission.status = 'pending' # Меняем статус на pending
returned_submission.feedback = '' # Очищаем feedback
returned_submission.checked_by = None # Очищаем checked_by
returned_submission.checked_at = None # Очищаем checked_at
returned_submission.score = None # Очищаем оценку
returned_submission.passed = False # Сбрасываем passed
returned_submission.ai_score = None # Очищаем AI оценку
returned_submission.ai_feedback = '' # Очищаем AI feedback
returned_submission.ai_checked_at = None # Очищаем AI checked_at
returned_submission.save()
submission = returned_submission
else:
# Создаем новое submission
# Определяем номер попытки
last_submission = HomeworkSubmission.objects.filter(
homework=homework,
student=user
).order_by('-attempt_number').first()
attempt_number = 1
if last_submission:
attempt_number = last_submission.attempt_number + 1
submission = HomeworkSubmission.objects.create(
homework=homework,
student=user,
attempt_number=attempt_number,
**validated_data
)
# Проверяем опоздание
submission.check_if_late()
# Обновляем статистику задания
homework.update_statistics()
return submission
# Шкала оценки ментором: от 1 до 5; зачёт при 3 и выше
MENTOR_GRADE_MIN = 1
MENTOR_GRADE_MAX = 5
MENTOR_PASS_THRESHOLD = 3
class HomeworkGradeSerializer(serializers.Serializer):
"""Сериализатор выставления оценки (ментор оценивает от 1 до 5)."""
score = serializers.IntegerField(
min_value=MENTOR_GRADE_MIN,
max_value=MENTOR_GRADE_MAX,
required=True
)
feedback = serializers.CharField(
required=False,
allow_blank=True
)
def validate_score(self, value):
"""Проверка балла: только 15."""
if value < MENTOR_GRADE_MIN or value > MENTOR_GRADE_MAX:
raise serializers.ValidationError(
f'Оценка должна быть от {MENTOR_GRADE_MIN} до {MENTOR_GRADE_MAX}'
)
return value
def save(self):
"""Выставить оценку."""
submission = self.instance
user = self.context['request'].user
score = self.validated_data['score']
submission.grade(
score=score,
feedback=self.validated_data.get('feedback', ''),
checked_by=user
)
# Зачёт при 3 и выше по шкале 15
submission.passed = score >= MENTOR_PASS_THRESHOLD
submission.save(update_fields=['passed'])
return submission
class HomeworkReturnSerializer(serializers.Serializer):
"""Сериализатор возврата на доработку."""
feedback = serializers.CharField(required=True)
def save(self):
"""Вернуть на доработку."""
submission = self.instance
user = self.context['request'].user
submission.return_for_revision(
feedback=self.validated_data['feedback'],
checked_by=user
)
return submission