uchill/backend/apps/homework/views.py

1082 lines
48 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.

"""
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')
students = list(homework.assigned_to.all())
for student in students:
invalidate_dashboard_cache(student.id, 'client')
# Отправляем уведомление о новом ДЗ только если НЕ отложенное
if not homework.fill_later:
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/
Также используется для публикации отложенных ДЗ (fill_later=True).
При публикации сбрасывается флаг fill_later.
"""
homework = self.get_object()
# Проверяем права
if homework.mentor != request.user:
return Response(
{'error': 'Только автор может опубликовать задание'},
status=status.HTTP_403_FORBIDDEN
)
# Запоминаем был ли это отложенный ДЗ (для отправки уведомления)
was_fill_later = homework.fill_later
# Сбрасываем fill_later при публикации
if homework.fill_later:
homework.fill_later = False
homework.save(update_fields=['fill_later'])
homework.publish()
# Инвалидируем кеш дашборда после публикации ДЗ
from apps.users.cache_utils import invalidate_dashboard_cache
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
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)
response_data = serializer.data
if was_fill_later:
response_data['was_fill_later'] = True
return Response(response_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=False, methods=['get'])
def fill_later_list(self, request):
"""
Получить список отложенных ДЗ (fill_later=True) для ментора.
GET /api/homework/homeworks/fill_later_list/
Возвращает ДЗ, которые были созданы с флагом "заполнить позже"
и ожидают заполнения ментором.
"""
if request.user.role != 'mentor':
return Response(
{'error': 'Только ментор может просматривать отложенные ДЗ'},
status=status.HTTP_403_FORBIDDEN
)
queryset = Homework.objects.filter(
mentor=request.user,
fill_later=True
).select_related('mentor', 'lesson', 'lesson__client', 'lesson__client__user').prefetch_related(
'assigned_to'
).order_by('-created_at')
serializer = HomeworkListSerializer(queryset, many=True, context={'request': request})
return Response({
'count': queryset.count(),
'results': 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)
# Проверка через ИИ: задание (текст + файлы), решение (текст + файлы) → комментарий и оценка 15
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')