614 lines
23 KiB
Python
614 lines
23 KiB
Python
"""
|
||
Сериализаторы для домашних заданий.
|
||
"""
|
||
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):
|
||
"""Проверка балла: только 1–5."""
|
||
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 и выше по шкале 1–5
|
||
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
|