""" Сериализаторы для домашних заданий. """ 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