1040 lines
46 KiB
Python
1040 lines
46 KiB
Python
"""
|
||
API views для домашних заданий.
|
||
"""
|
||
import logging
|
||
import html
|
||
from rest_framework import viewsets, status
|
||
from rest_framework.decorators import action
|
||
from rest_framework.response import Response
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
from .models import Homework, HomeworkSubmission, HomeworkFile, HomeworkAIAgent
|
||
from .serializers import (
|
||
HomeworkSerializer,
|
||
HomeworkListSerializer,
|
||
HomeworkCreateSerializer,
|
||
HomeworkSubmissionSerializer,
|
||
HomeworkSubmissionCreateSerializer,
|
||
HomeworkGradeSerializer,
|
||
HomeworkReturnSerializer,
|
||
HomeworkFileSerializer,
|
||
HomeworkAIAgentSerializer,
|
||
)
|
||
from .permissions import IsHomeworkMentor, IsSubmissionOwnerOrMentor
|
||
from django.contrib.auth import get_user_model
|
||
from config.throttling import UploadRateThrottle
|
||
|
||
User = get_user_model()
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _student_display_name(submission):
|
||
"""Имя ученика (только имя, без фамилии) — тот, кто отправил ДЗ."""
|
||
if not submission or not getattr(submission, 'student', None):
|
||
return None
|
||
s = submission.student
|
||
name = (s.first_name or (s.get_short_name() if getattr(s, 'email', None) else '') or '').strip()
|
||
return name or f"Студент (id: {s.pk})"
|
||
|
||
|
||
class HomeworkViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
ViewSet для управления домашними заданиями.
|
||
|
||
list: Список ДЗ
|
||
create: Создать ДЗ
|
||
retrieve: Получить ДЗ
|
||
update: Обновить ДЗ
|
||
destroy: Удалить ДЗ
|
||
publish: Опубликовать ДЗ
|
||
archive: Архивировать ДЗ
|
||
statistics: Статистика ДЗ
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def get_throttles(self):
|
||
"""Применяем throttling только для создания (загрузки файлов)."""
|
||
if self.action == 'create':
|
||
return [UploadRateThrottle()]
|
||
return super().get_throttles()
|
||
|
||
def get_permissions(self):
|
||
"""Определение прав доступа для разных действий."""
|
||
if self.action in ['update', 'partial_update', 'destroy']:
|
||
return [IsAuthenticated(), IsHomeworkMentor()]
|
||
return [IsAuthenticated()]
|
||
|
||
def get_queryset(self):
|
||
"""Получение ДЗ."""
|
||
user = self.request.user
|
||
filter_status = self.request.query_params.get('status')
|
||
|
||
if user.role == 'mentor':
|
||
# Ментор видит свои задания
|
||
queryset = Homework.objects.filter(
|
||
mentor=user
|
||
).select_related('mentor', 'lesson').prefetch_related(
|
||
'assigned_to',
|
||
'submissions__student',
|
||
'files',
|
||
'assignment_files' # Файлы задания (прямая связь)
|
||
)
|
||
elif user.role == 'parent':
|
||
# Родитель видит задания своих детей
|
||
from apps.users.models import Parent, Client
|
||
try:
|
||
parent = Parent.objects.get(user=user)
|
||
children_ids = parent.children.values_list('user_id', flat=True)
|
||
|
||
# Если указан child_id (user_id ребенка), фильтруем по конкретному ребенку
|
||
child_id = self.request.query_params.get('child_id')
|
||
if child_id:
|
||
try:
|
||
# Проверяем, что это ребенок родителя
|
||
child_client = Client.objects.get(user_id=child_id)
|
||
if child_client.id in parent.children.values_list('id', flat=True):
|
||
# Фильтруем задания, назначенные этому ребенку
|
||
queryset = Homework.objects.filter(
|
||
assigned_to=child_client.user,
|
||
status='published'
|
||
).select_related('mentor', 'lesson').prefetch_related(
|
||
'assigned_to',
|
||
'submissions__student',
|
||
'files',
|
||
'assignment_files'
|
||
)
|
||
else:
|
||
queryset = Homework.objects.none()
|
||
except (Client.DoesNotExist, ValueError):
|
||
queryset = Homework.objects.none()
|
||
else:
|
||
# Если child_id не указан, показываем задания всех детей
|
||
queryset = Homework.objects.filter(
|
||
assigned_to__id__in=children_ids,
|
||
status='published'
|
||
).select_related('mentor', 'lesson').prefetch_related(
|
||
'assigned_to',
|
||
'submissions__student',
|
||
'files',
|
||
'assignment_files'
|
||
).distinct()
|
||
except Parent.DoesNotExist:
|
||
queryset = Homework.objects.none()
|
||
else:
|
||
# Студент видит назначенные ему задания
|
||
queryset = Homework.objects.filter(
|
||
assigned_to=user,
|
||
status='published'
|
||
).select_related('mentor', 'lesson').prefetch_related(
|
||
'assigned_to',
|
||
'submissions__student',
|
||
'files',
|
||
'assignment_files' # Файлы задания (прямая связь)
|
||
)
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'title', 'description', 'mentor_id', 'lesson_id',
|
||
'deadline', 'max_score', 'passing_score', 'status', 'fill_later',
|
||
'total_submissions', 'checked_submissions', 'returned_submissions',
|
||
'ai_draft_submissions', 'average_score',
|
||
'created_at', 'updated_at', 'published_at'
|
||
)
|
||
|
||
# Фильтрация по статусу submissions
|
||
if filter_status:
|
||
if filter_status == 'pending':
|
||
# Ожидают: нет решений или все решения в статусе pending
|
||
if user.role == 'mentor':
|
||
# Для ментора: задания, где нет решений вообще
|
||
queryset = queryset.filter(
|
||
submissions__isnull=True
|
||
).distinct()
|
||
else:
|
||
# Для студента: задания без его решений
|
||
queryset = queryset.exclude(
|
||
submissions__student=user
|
||
).distinct()
|
||
elif filter_status == 'submitted':
|
||
# На проверке: есть решения в статусе checking или pending (но не graded)
|
||
if user.role == 'mentor':
|
||
# Для ментора: задания с решениями в статусе checking или pending
|
||
queryset = queryset.filter(
|
||
submissions__status__in=['checking', 'pending']
|
||
).exclude(
|
||
submissions__status='graded'
|
||
).distinct()
|
||
else:
|
||
# Для студента: его решения в статусе checking или pending
|
||
queryset = queryset.filter(
|
||
submissions__student=user,
|
||
submissions__status__in=['checking', 'pending']
|
||
).distinct()
|
||
elif filter_status == 'reviewed':
|
||
# Проверено: есть решения в статусе graded
|
||
if user.role == 'mentor':
|
||
# Для ментора: задания с хотя бы одним решением в статусе graded
|
||
queryset = queryset.filter(
|
||
submissions__status='graded'
|
||
).distinct()
|
||
else:
|
||
# Для студента: его решения в статусе graded
|
||
queryset = queryset.filter(
|
||
submissions__student=user,
|
||
submissions__status='graded'
|
||
).distinct()
|
||
|
||
return queryset
|
||
|
||
def get_serializer_class(self):
|
||
"""Выбор сериализатора."""
|
||
if self.action == 'list':
|
||
return HomeworkListSerializer
|
||
elif self.action == 'create':
|
||
return HomeworkCreateSerializer
|
||
elif self.action in ['update', 'partial_update']:
|
||
return HomeworkCreateSerializer # Используем тот же сериализатор для обновления
|
||
return HomeworkSerializer
|
||
|
||
def create(self, request, *args, **kwargs):
|
||
"""Создание ДЗ."""
|
||
serializer = self.get_serializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
homework = serializer.save()
|
||
|
||
# Инвалидируем кеш дашборда после создания ДЗ
|
||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
||
# Оптимизация: используем list() для кеширования запроса
|
||
students = list(homework.assigned_to.all())
|
||
for student in students:
|
||
invalidate_dashboard_cache(student.id, 'client')
|
||
|
||
# Отправляем уведомление о новом ДЗ
|
||
from apps.notifications.services import NotificationService
|
||
NotificationService.send_homework_notification(homework, 'homework_assigned')
|
||
|
||
response_serializer = HomeworkSerializer(homework)
|
||
return Response(
|
||
response_serializer.data,
|
||
status=status.HTTP_201_CREATED
|
||
)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def publish(self, request, pk=None):
|
||
"""
|
||
Опубликовать ДЗ.
|
||
|
||
POST /api/homework/homeworks/{id}/publish/
|
||
"""
|
||
homework = self.get_object()
|
||
|
||
# Проверяем права
|
||
if homework.mentor != request.user:
|
||
return Response(
|
||
{'error': 'Только автор может опубликовать задание'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
homework.publish()
|
||
|
||
# Инвалидируем кеш дашборда после публикации ДЗ
|
||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
||
# Оптимизация: используем list() для кеширования запроса
|
||
students = list(homework.assigned_to.all())
|
||
for student in students:
|
||
invalidate_dashboard_cache(student.id, 'client')
|
||
|
||
# Отправляем уведомления студентам
|
||
from apps.notifications.services import NotificationService
|
||
for student in students:
|
||
NotificationService.create_notification_with_telegram(
|
||
recipient=student,
|
||
notification_type='homework_assigned',
|
||
title='📚 Новое домашнее задание',
|
||
message=f'Новое домашнее задание: "{homework.title}"',
|
||
priority='normal',
|
||
action_url=f'/homework/{homework.id}/',
|
||
content_object=homework
|
||
)
|
||
|
||
serializer = HomeworkSerializer(homework)
|
||
return Response(serializer.data)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def archive(self, request, pk=None):
|
||
"""
|
||
Архивировать ДЗ.
|
||
|
||
POST /api/homework/homeworks/{id}/archive/
|
||
"""
|
||
homework = self.get_object()
|
||
|
||
# Проверяем права
|
||
if homework.mentor != request.user:
|
||
return Response(
|
||
{'error': 'Только автор может архивировать задание'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
homework.archive()
|
||
|
||
serializer = HomeworkSerializer(homework)
|
||
return Response(serializer.data)
|
||
|
||
@action(detail=True, methods=['get'])
|
||
def statistics(self, request, pk=None):
|
||
"""
|
||
Получить статистику ДЗ.
|
||
|
||
GET /api/homework/homeworks/{id}/statistics/
|
||
"""
|
||
homework = self.get_object()
|
||
|
||
# Проверяем права
|
||
if homework.mentor != request.user:
|
||
return Response(
|
||
{'error': 'Только автор может просмотреть статистику'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Оптимизация: используем один запрос с агрегацией вместо множества отдельных count()
|
||
from django.db.models import Count, Q, Avg
|
||
|
||
submissions_queryset = homework.submissions.all()
|
||
|
||
# Получаем все подсчеты одним запросом через агрегацию
|
||
aggregated = submissions_queryset.aggregate(
|
||
total_submissions=Count('id'),
|
||
unique_students=Count('student', distinct=True),
|
||
pending=Count('id', filter=Q(status='pending')),
|
||
checking=Count('id', filter=Q(status='checking')),
|
||
graded=Count('id', filter=Q(status='graded')),
|
||
returned=Count('id', filter=Q(status='returned')),
|
||
passed=Count('id', filter=Q(passed=True)),
|
||
failed=Count('id', filter=Q(passed=False, status='graded')),
|
||
on_time=Count('id', filter=Q(is_late=False)),
|
||
late=Count('id', filter=Q(is_late=True)),
|
||
)
|
||
|
||
# Оптимизация: используем count() только один раз
|
||
total_assigned = homework.assigned_to.count()
|
||
|
||
stats = {
|
||
'total_assigned': total_assigned,
|
||
'total_submissions': aggregated['total_submissions'],
|
||
'unique_students': aggregated['unique_students'],
|
||
'pending': aggregated['pending'],
|
||
'checking': aggregated['checking'],
|
||
'graded': aggregated['graded'],
|
||
'returned': aggregated['returned'],
|
||
'passed': aggregated['passed'],
|
||
'failed': aggregated['failed'],
|
||
'average_score': homework.average_score,
|
||
'on_time': aggregated['on_time'],
|
||
'late': aggregated['late'],
|
||
}
|
||
|
||
# Распределение баллов - оптимизируем через один запрос с фильтрацией
|
||
graded_submissions = submissions_queryset.filter(status='graded').values_list('score', flat=True)
|
||
if graded_submissions.exists():
|
||
score_distribution = []
|
||
# Получаем все оценки одним запросом и считаем в Python
|
||
scores = list(graded_submissions)
|
||
for i in range(0, homework.max_score + 1, 10):
|
||
count = sum(1 for score in scores if score is not None and i <= score < i + 10)
|
||
score_distribution.append({
|
||
'range': f'{i}-{i+9}',
|
||
'count': count
|
||
})
|
||
stats['score_distribution'] = score_distribution
|
||
|
||
return Response(stats)
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def my_homeworks(self, request):
|
||
"""
|
||
Мои домашние задания (для студентов).
|
||
|
||
GET /api/homework/homeworks/my_homeworks/
|
||
"""
|
||
user = request.user
|
||
homeworks = Homework.objects.filter(
|
||
assigned_to=user,
|
||
status='published'
|
||
).select_related('mentor', 'lesson').prefetch_related(
|
||
'assigned_to',
|
||
'submissions__student'
|
||
).only(
|
||
'id', 'title', 'description', 'mentor_id', 'lesson_id',
|
||
'deadline', 'max_score', 'passing_score', 'status', 'fill_later',
|
||
'total_submissions', 'checked_submissions', 'average_score',
|
||
'created_at', 'updated_at', 'published_at'
|
||
)
|
||
|
||
serializer = HomeworkListSerializer(homeworks, many=True, context={'request': request})
|
||
return Response(serializer.data)
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def created_by_me(self, request):
|
||
"""
|
||
Созданные мной задания (для менторов).
|
||
|
||
GET /api/homework/homeworks/created_by_me/
|
||
"""
|
||
homeworks = Homework.objects.filter(
|
||
mentor=request.user
|
||
).select_related('mentor', 'lesson').prefetch_related(
|
||
'assigned_to',
|
||
'submissions__student'
|
||
).only(
|
||
'id', 'title', 'description', 'mentor_id', 'lesson_id',
|
||
'deadline', 'max_score', 'passing_score', 'status', 'fill_later',
|
||
'total_submissions', 'checked_submissions', 'average_score',
|
||
'created_at', 'updated_at', 'published_at'
|
||
)
|
||
|
||
serializer = HomeworkListSerializer(homeworks, many=True, context={'request': request})
|
||
return Response(serializer.data)
|
||
|
||
|
||
class HomeworkSubmissionViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
ViewSet для управления решениями ДЗ.
|
||
|
||
list: Список решений
|
||
create: Создать решение
|
||
retrieve: Получить решение
|
||
update: Обновить решение
|
||
destroy: Удалить решение
|
||
grade: Выставить оценку
|
||
return_for_revision: Вернуть на доработку
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated, IsSubmissionOwnerOrMentor]
|
||
|
||
def get_queryset(self):
|
||
"""Получение решений."""
|
||
user = self.request.user
|
||
homework_id = self.request.query_params.get('homework_id')
|
||
child_id = self.request.query_params.get('child_id')
|
||
|
||
queryset = HomeworkSubmission.objects.all()
|
||
|
||
# Оптимизация: для списка используем только select_related для необходимых полей
|
||
# и only() для ограничения полей (но без конфликтующих полей)
|
||
if self.action == 'list':
|
||
queryset = queryset.select_related(
|
||
'homework',
|
||
'homework__mentor',
|
||
'student'
|
||
).prefetch_related('files').only(
|
||
'id', 'homework_id', 'student_id', 'checked_by_id', 'status', 'score',
|
||
'passed', 'is_late', 'submitted_at', 'checked_at', 'feedback',
|
||
'updated_at'
|
||
)
|
||
else:
|
||
# Для детального просмотра используем полный select_related
|
||
queryset = queryset.select_related(
|
||
'homework',
|
||
'homework__mentor',
|
||
'student',
|
||
'checked_by'
|
||
).prefetch_related('files')
|
||
|
||
# Фильтр по заданию
|
||
if homework_id:
|
||
queryset = queryset.filter(homework_id=homework_id)
|
||
|
||
# Ментор видит решения своих заданий
|
||
if user.role == 'mentor':
|
||
queryset = queryset.filter(homework__mentor=user)
|
||
elif user.role == 'parent' and child_id:
|
||
# Родитель с child_id видит решения выбранного ребёнка
|
||
try:
|
||
from apps.users.models import Client, Parent
|
||
parent_profile = Parent.objects.get(user=user)
|
||
child_client = Client.objects.get(user_id=child_id)
|
||
if child_client in parent_profile.children.all():
|
||
queryset = queryset.filter(student=child_client.user)
|
||
else:
|
||
queryset = queryset.none()
|
||
except (Client.DoesNotExist, Parent.DoesNotExist, ValueError):
|
||
queryset = queryset.none()
|
||
else:
|
||
# Студент видит только свои решения
|
||
queryset = queryset.filter(student=user)
|
||
|
||
return queryset
|
||
|
||
def get_serializer_class(self):
|
||
"""Выбор сериализатора."""
|
||
if self.action == 'create':
|
||
return HomeworkSubmissionCreateSerializer
|
||
elif self.action == 'grade':
|
||
return HomeworkGradeSerializer
|
||
elif self.action == 'return_for_revision':
|
||
return HomeworkReturnSerializer
|
||
return HomeworkSubmissionSerializer
|
||
|
||
def create(self, request, *args, **kwargs):
|
||
"""Создание решения."""
|
||
# Получаем все файлы из запроса (может быть несколько с одинаковым именем поля)
|
||
files = request.FILES.getlist('attachment')
|
||
|
||
# Для сериализатора оставляем только первый файл (если есть)
|
||
# Остальные обработаем после создания submission
|
||
if files:
|
||
request.data._mutable = True
|
||
request.data['attachment'] = files[0]
|
||
request.data._mutable = False
|
||
|
||
# Проверяем, есть ли submission со статусом 'returned' для обновления
|
||
homework_id = request.data.get('homework_id')
|
||
from .models import HomeworkSubmission, HomeworkFile
|
||
returned_submission_id = None
|
||
if homework_id:
|
||
try:
|
||
returned_submission = HomeworkSubmission.objects.filter(
|
||
homework_id=homework_id,
|
||
student=request.user,
|
||
status='returned'
|
||
).order_by('-submitted_at').first()
|
||
if returned_submission:
|
||
returned_submission_id = returned_submission.id
|
||
except:
|
||
pass
|
||
|
||
serializer = self.get_serializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
submission = serializer.save()
|
||
|
||
# Если обновляется существующее submission (было возвращено на доработку),
|
||
# удаляем старые дополнительные файлы
|
||
if returned_submission_id and submission.id == returned_submission_id:
|
||
old_files = HomeworkFile.objects.filter(
|
||
submission=submission,
|
||
file_type='submission'
|
||
)
|
||
if old_files.exists():
|
||
# Удаляем старые файлы при перезаписи submission
|
||
old_files.delete()
|
||
|
||
# Сохраняем остальные файлы как HomeworkFile
|
||
if len(files) > 1:
|
||
for file in files[1:]:
|
||
HomeworkFile.objects.create(
|
||
submission=submission,
|
||
file_type='submission',
|
||
file=file,
|
||
filename=file.name,
|
||
file_size=file.size,
|
||
uploaded_by=request.user
|
||
)
|
||
|
||
# Инвалидируем кеш дашборда после создания решения ДЗ
|
||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||
invalidate_dashboard_cache(submission.student.id, 'client')
|
||
invalidate_dashboard_cache(submission.homework.mentor.id, 'mentor')
|
||
|
||
# Отправляем уведомление ментору
|
||
from apps.notifications.services import NotificationService
|
||
NotificationService.create_notification_with_telegram(
|
||
recipient=submission.homework.mentor,
|
||
notification_type='homework_submitted',
|
||
title='📝 ДЗ сдано',
|
||
message=f'{submission.student.get_full_name()} сдал ДЗ "{submission.homework.title}"',
|
||
priority='normal',
|
||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||
content_object=submission
|
||
)
|
||
|
||
# По настройкам ментора: доверять AI — запускаем проверку (черновик или публикация)
|
||
mentor = submission.homework.mentor
|
||
mentor.refresh_from_db(fields=['ai_trust_draft', 'ai_trust_publish'])
|
||
if getattr(mentor, 'ai_trust_publish', False):
|
||
from .tasks import run_mentor_ai_check_submission
|
||
logger.info(
|
||
"ДЗ: запуск AI проверки (публикация) для submission_id=%s, mentor_id=%s",
|
||
submission.id, mentor.id
|
||
)
|
||
run_mentor_ai_check_submission.delay(submission.id, publish=True)
|
||
elif getattr(mentor, 'ai_trust_draft', False):
|
||
from .tasks import run_mentor_ai_check_submission
|
||
logger.info(
|
||
"ДЗ: запуск AI проверки (черновик) для submission_id=%s, mentor_id=%s",
|
||
submission.id, mentor.id
|
||
)
|
||
run_mentor_ai_check_submission.delay(submission.id, publish=False)
|
||
|
||
response_serializer = HomeworkSubmissionSerializer(submission)
|
||
return Response(
|
||
response_serializer.data,
|
||
status=status.HTTP_201_CREATED
|
||
)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def check_with_ai(self, request, pk=None):
|
||
"""
|
||
Проверить решение через AI.
|
||
|
||
POST /api/homework/submissions/{id}/check_with_ai/
|
||
"""
|
||
submission = self.get_object()
|
||
|
||
# Проверяем права доступа (только ментор)
|
||
if request.user.role != 'mentor':
|
||
return Response(
|
||
{'error': 'Только ментор может использовать AI проверку'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Проверяем что это задание ментора
|
||
if submission.homework.mentor != request.user:
|
||
return Response(
|
||
{'error': 'Вы не можете проверять это задание'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Импортируем сервис
|
||
from .ai_service import get_ai_service
|
||
from django.core.files.storage import default_storage
|
||
|
||
# Файлы задания: имена, пути (если есть), содержимое (если path недоступен — S3 и т.д.)
|
||
homework_files = []
|
||
homework_file_paths = []
|
||
homework_file_contents = []
|
||
def _add_homework_file(name):
|
||
homework_files.append(name)
|
||
path_ok = False
|
||
try:
|
||
p = default_storage.path(name)
|
||
if p:
|
||
homework_file_paths.append(p)
|
||
path_ok = True
|
||
except Exception:
|
||
pass
|
||
if not path_ok:
|
||
try:
|
||
with default_storage.open(name, 'rb') as f:
|
||
data = f.read(2 * 1024 * 1024 + 1) # лимит 2 МБ
|
||
fname = name.split('/')[-1] if '/' in name else name
|
||
homework_file_contents.append((fname, data[: 2 * 1024 * 1024]))
|
||
except Exception:
|
||
pass
|
||
if submission.homework.attachment:
|
||
_add_homework_file(submission.homework.attachment.name)
|
||
for f in submission.homework.assignment_files.all():
|
||
if f.file:
|
||
_add_homework_file(f.file.name)
|
||
# Файлы решения
|
||
submission_files = []
|
||
submission_file_paths = []
|
||
submission_file_contents = []
|
||
def _add_submission_file(name):
|
||
submission_files.append(name)
|
||
path_ok = False
|
||
try:
|
||
p = default_storage.path(name)
|
||
if p:
|
||
submission_file_paths.append(p)
|
||
path_ok = True
|
||
except Exception:
|
||
pass
|
||
if not path_ok:
|
||
try:
|
||
with default_storage.open(name, 'rb') as f:
|
||
data = f.read(2 * 1024 * 1024 + 1)
|
||
fname = name.split('/')[-1] if '/' in name else name
|
||
submission_file_contents.append((fname, data[: 2 * 1024 * 1024]))
|
||
except Exception:
|
||
pass
|
||
if submission.attachment:
|
||
_add_submission_file(submission.attachment.name)
|
||
for file_obj in HomeworkFile.objects.filter(submission=submission, file_type='submission'):
|
||
if file_obj.file:
|
||
_add_submission_file(file_obj.file.name)
|
||
# Проверка через ИИ: задание (текст + файлы), решение (текст + файлы) → комментарий и оценка 1–5
|
||
ai_service = get_ai_service()
|
||
result = ai_service.check_submission(
|
||
homework_title=submission.homework.title,
|
||
homework_description=submission.homework.description or '',
|
||
homework_max_score=5,
|
||
submission_content=submission.content or '',
|
||
submission_files=submission_files,
|
||
homework_files=homework_files,
|
||
homework_file_paths=homework_file_paths,
|
||
submission_file_paths=submission_file_paths,
|
||
homework_file_contents=homework_file_contents,
|
||
submission_file_contents=submission_file_contents,
|
||
student_name=_student_display_name(submission),
|
||
)
|
||
|
||
if not result.get('success'):
|
||
return Response(
|
||
{'error': result.get('error', 'Ошибка AI проверки')},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
# Сохраняем результат AI проверки
|
||
submission.ai_score = result.get('score')
|
||
submission.ai_feedback = result.get('feedback')
|
||
submission.ai_checked_at = timezone.now()
|
||
submission.save(update_fields=['ai_score', 'ai_feedback', 'ai_checked_at'])
|
||
|
||
from .utils import feedback_to_html
|
||
payload = {
|
||
'success': True,
|
||
'ai_score': submission.ai_score,
|
||
'ai_feedback': submission.ai_feedback,
|
||
'ai_feedback_html': feedback_to_html(submission.ai_feedback or ''),
|
||
'ai_checked_at': submission.ai_checked_at,
|
||
'message': 'AI проверка завершена успешно'
|
||
}
|
||
if result.get('usage'):
|
||
payload['usage'] = result['usage']
|
||
if result.get('skipped_reason'):
|
||
payload['skipped_reason'] = result['skipped_reason']
|
||
return Response(payload)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def grade(self, request, pk=None):
|
||
"""
|
||
Выставить оценку.
|
||
|
||
POST /api/homework/submissions/{id}/grade/
|
||
Body: {
|
||
"score": 85,
|
||
"feedback": "Отличная работа!"
|
||
}
|
||
"""
|
||
submission = self.get_object()
|
||
|
||
# Проверяем права
|
||
if submission.homework.mentor != request.user:
|
||
return Response(
|
||
{'error': 'Только ментор может выставить оценку'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
serializer = HomeworkGradeSerializer(submission, data=request.data, partial=True, context={'request': request})
|
||
serializer.is_valid(raise_exception=True)
|
||
submission = serializer.save()
|
||
|
||
# Инвалидируем кеш дашборда после оценки ДЗ
|
||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||
invalidate_dashboard_cache(submission.student.id, 'client')
|
||
invalidate_dashboard_cache(submission.homework.mentor.id, 'mentor')
|
||
|
||
# Отправляем уведомление студенту
|
||
from apps.notifications.services import NotificationService
|
||
|
||
feedback_text = ""
|
||
if submission.feedback:
|
||
# Экранируем HTML теги в комментарии, чтобы не сломать разметку Telegram
|
||
escaped_feedback = html.escape(submission.feedback)
|
||
feedback_text = f"\n\n💬 <b>Комментарий:</b>\n{escaped_feedback}"
|
||
|
||
NotificationService.create_notification_with_telegram(
|
||
recipient=submission.student,
|
||
notification_type='homework_reviewed',
|
||
title='✅ ДЗ проверено',
|
||
message=f'Проверено ДЗ "{submission.homework.title}". Оценка: {submission.score}/5{feedback_text}',
|
||
priority='normal',
|
||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||
content_object=submission
|
||
)
|
||
|
||
response_serializer = HomeworkSubmissionSerializer(submission)
|
||
return Response(response_serializer.data)
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def by_subject(self, request):
|
||
"""
|
||
Получить ДЗ с оценками по выбранному предмету для графика прогресса.
|
||
|
||
GET /api/homework/submissions/by_subject/
|
||
Query params:
|
||
- subject: название предмета (обязательно)
|
||
- start_date: начальная дата (YYYY-MM-DD, опционально)
|
||
- end_date: конечная дата (YYYY-MM-DD, опционально)
|
||
- child_id: ID ребенка (для родителей, опционально)
|
||
|
||
Returns: список ДЗ с оценками, отсортированный по дате проверки
|
||
"""
|
||
from django.utils.dateparse import parse_date
|
||
from django.db.models import Q
|
||
from apps.schedule.models import Lesson
|
||
|
||
user = request.user
|
||
subject = request.query_params.get('subject')
|
||
start_date = request.query_params.get('start_date')
|
||
end_date = request.query_params.get('end_date')
|
||
child_id = request.query_params.get('child_id')
|
||
|
||
if not subject:
|
||
return Response(
|
||
{'error': 'Параметр subject обязателен'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
# Определяем для какого студента получаем ДЗ
|
||
student_user = user
|
||
if user.role == 'parent' and child_id:
|
||
try:
|
||
from apps.users.models import Client, Parent
|
||
# Получаем профиль родителя
|
||
parent_profile = Parent.objects.get(user=user)
|
||
# child_id - это user_id ребенка, находим Client через User
|
||
child_client = Client.objects.get(user_id=child_id)
|
||
# Проверяем, что ребенок принадлежит этому родителю
|
||
if child_client not in parent_profile.children.all():
|
||
return Response(
|
||
{'error': 'Ребенок не найден'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
student_user = child_client.user
|
||
except (Client.DoesNotExist, Parent.DoesNotExist):
|
||
return Response(
|
||
{'error': 'Ребенок не найден'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
elif user.role == 'mentor' and child_id:
|
||
try:
|
||
from apps.users.models import Client
|
||
# child_id — user_id студента; ментор получает ДЗ своего ученика
|
||
student_user = User.objects.get(id=child_id)
|
||
child_client = Client.objects.get(user=student_user)
|
||
# Проверяем, что у ментора есть занятия с этим студентом
|
||
from apps.schedule.models import Lesson
|
||
if not Lesson.objects.filter(mentor=user, client=child_client).exists():
|
||
return Response(
|
||
{'error': 'Студент не найден'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
except (User.DoesNotExist, Client.DoesNotExist):
|
||
return Response(
|
||
{'error': 'Студент не найден'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
elif user.role != 'client':
|
||
return Response(
|
||
{'error': 'Доступ запрещен'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Базовый queryset: только проверенные ДЗ с оценками для студента
|
||
queryset = HomeworkSubmission.objects.filter(
|
||
student=student_user,
|
||
status='graded',
|
||
score__isnull=False,
|
||
checked_at__isnull=False
|
||
).select_related(
|
||
'homework',
|
||
'homework__lesson',
|
||
'homework__lesson__subject',
|
||
'homework__lesson__mentor_subject',
|
||
'homework__mentor',
|
||
'student'
|
||
)
|
||
|
||
# Фильтр по предмету через урок
|
||
# Проверяем и subject (ForeignKey) и subject_name (legacy поле)
|
||
# Также проверяем, что урок существует (homework__lesson__isnull=False)
|
||
# Используем OR для всех возможных вариантов названия предмета
|
||
subject_filter = (
|
||
Q(homework__lesson__isnull=False) & (
|
||
Q(homework__lesson__subject__name__iexact=subject) |
|
||
Q(homework__lesson__subject_name__iexact=subject) |
|
||
Q(homework__lesson__mentor_subject__name__iexact=subject)
|
||
)
|
||
)
|
||
|
||
# Также проверяем, если в сериализаторе homework есть поле lesson_subject
|
||
# Но это поле вычисляемое, поэтому фильтруем только через урок
|
||
|
||
# Логирование для отладки
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Сначала получаем все ДЗ без фильтра по предмету для отладки
|
||
debug_queryset = queryset.all()
|
||
logger.info(f'[by_subject] Всего ДЗ для студента {student_user.id}: {debug_queryset.count()}')
|
||
|
||
# Проверяем, есть ли ДЗ с уроками
|
||
with_lessons = debug_queryset.filter(homework__lesson__isnull=False)
|
||
logger.info(f'[by_subject] ДЗ с уроками: {with_lessons.count()}')
|
||
|
||
# Проверяем предметы в уроках
|
||
if with_lessons.exists():
|
||
sample = with_lessons.first()
|
||
if sample.homework.lesson:
|
||
lesson = sample.homework.lesson
|
||
logger.info(f'[by_subject] Пример урока: id={lesson.id}, subject={lesson.subject}, subject_name={lesson.subject_name}, mentor_subject={lesson.mentor_subject}')
|
||
if lesson.subject:
|
||
logger.info(f'[by_subject] subject.name={lesson.subject.name}')
|
||
if lesson.mentor_subject:
|
||
logger.info(f'[by_subject] mentor_subject.name={lesson.mentor_subject.name}')
|
||
|
||
queryset = queryset.filter(subject_filter)
|
||
|
||
logger.info(f'[by_subject] После фильтра по предмету "{subject}": {queryset.count()}')
|
||
|
||
# Фильтр по дате проверки
|
||
if start_date:
|
||
try:
|
||
start = parse_date(start_date)
|
||
if start:
|
||
queryset = queryset.filter(checked_at__date__gte=start)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
if end_date:
|
||
try:
|
||
end = parse_date(end_date)
|
||
if end:
|
||
queryset = queryset.filter(checked_at__date__lte=end)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# Сортируем по дате проверки
|
||
queryset = queryset.order_by('checked_at')
|
||
|
||
# Сериализуем только необходимые поля для графика
|
||
serializer = HomeworkSubmissionSerializer(queryset, many=True)
|
||
|
||
return Response({
|
||
'count': queryset.count(),
|
||
'results': serializer.data
|
||
})
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def return_for_revision(self, request, pk=None):
|
||
"""
|
||
Вернуть на доработку.
|
||
|
||
POST /api/homework/submissions/{id}/return_for_revision/
|
||
Body: {
|
||
"feedback": "Необходимо доработать..."
|
||
}
|
||
"""
|
||
submission = self.get_object()
|
||
|
||
# Проверяем права (только ментор задания)
|
||
if submission.homework.mentor != request.user:
|
||
return Response(
|
||
{'error': 'Только ментор может вернуть на доработку'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
serializer = self.get_serializer(submission, data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
submission = serializer.save()
|
||
|
||
# Отправляем уведомление студенту
|
||
from apps.notifications.services import NotificationService
|
||
msg = f'ДЗ "{submission.homework.title}" возвращено на доработку. Нужно отправить решение заново.'
|
||
if submission.feedback and str(submission.feedback).strip():
|
||
comment = (submission.feedback[:500] + '…') if len(submission.feedback) > 500 else submission.feedback
|
||
msg += f'\n\n💬 Комментарий:\n{comment}'
|
||
NotificationService.create_notification_with_telegram(
|
||
recipient=submission.student,
|
||
notification_type='homework_returned',
|
||
title='🔄 ДЗ возвращено на доработку',
|
||
message=msg,
|
||
priority='normal',
|
||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||
content_object=submission
|
||
)
|
||
|
||
response_serializer = HomeworkSubmissionSerializer(submission)
|
||
return Response(response_serializer.data)
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def my_submissions(self, request):
|
||
"""
|
||
Мои решения (для студентов).
|
||
|
||
GET /api/homework/submissions/my_submissions/
|
||
"""
|
||
submissions = HomeworkSubmission.objects.filter(
|
||
student=request.user
|
||
).select_related(
|
||
'homework', 'homework__mentor', 'homework__lesson',
|
||
'homework__lesson__subject', 'homework__lesson__mentor_subject',
|
||
'student', 'checked_by'
|
||
).only(
|
||
'id', 'homework_id', 'student_id', 'checked_by_id', 'content',
|
||
'score', 'feedback', 'status', 'submitted_at', 'checked_at',
|
||
'is_late', 'passed', 'updated_at'
|
||
)
|
||
|
||
serializer = HomeworkSubmissionSerializer(submissions, many=True)
|
||
return Response(serializer.data)
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def pending(self, request):
|
||
"""
|
||
Решения ожидающие проверки (для менторов).
|
||
|
||
GET /api/homework/submissions/pending/
|
||
"""
|
||
submissions = HomeworkSubmission.objects.filter(
|
||
homework__mentor=request.user,
|
||
status='pending'
|
||
).select_related('homework', 'homework__mentor', 'student', 'checked_by').only(
|
||
'id', 'homework_id', 'student_id', 'checked_by_id', 'content',
|
||
'score', 'feedback', 'status', 'submitted_at', 'checked_at',
|
||
'is_late', 'passed', 'updated_at'
|
||
)
|
||
|
||
serializer = HomeworkSubmissionSerializer(submissions, many=True)
|
||
return Response(serializer.data)
|
||
|
||
|
||
class HomeworkFileViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
ViewSet для управления файлами ДЗ.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
serializer_class = HomeworkFileSerializer
|
||
|
||
def get_queryset(self):
|
||
"""Получение файлов."""
|
||
user = self.request.user
|
||
|
||
queryset = HomeworkFile.objects.filter(
|
||
models.Q(homework__mentor=user) |
|
||
models.Q(submission__student=user) |
|
||
models.Q(uploaded_by=user)
|
||
).select_related('homework', 'homework__mentor', 'submission', 'submission__student', 'uploaded_by')
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'homework_id', 'submission_id', 'file_type', 'file',
|
||
'filename', 'file_size', 'uploaded_by_id', 'created_at'
|
||
)
|
||
|
||
return queryset.order_by('-created_at')
|
||
|
||
def perform_create(self, serializer):
|
||
"""Создание файла."""
|
||
serializer.save(uploaded_by=self.request.user)
|
||
|
||
|
||
class HomeworkAIAgentViewSet(viewsets.ReadOnlyModelViewSet):
|
||
"""
|
||
Список ИИ-агентов для проверки ДЗ (только чтение).
|
||
GET /api/homework/ai-agents/ — какие у нас есть ИИ-агенты.
|
||
"""
|
||
permission_classes = [IsAuthenticated]
|
||
serializer_class = HomeworkAIAgentSerializer
|
||
queryset = HomeworkAIAgent.objects.filter(is_active=True).order_by('order', 'name')
|