1550 lines
70 KiB
Python
1550 lines
70 KiB
Python
"""
|
||
Views для расписания.
|
||
"""
|
||
from rest_framework import viewsets, status, generics, serializers
|
||
from rest_framework.decorators import action
|
||
from rest_framework.response import Response
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from django.utils import timezone
|
||
from django.db.models import Q
|
||
from datetime import datetime, timedelta
|
||
import uuid
|
||
|
||
from .models import Lesson, LessonTemplate, TimeSlot, Availability, LessonFile, LessonHomeworkSubmission, Subject, MentorSubject
|
||
from apps.materials.models import Material
|
||
from apps.materials.views import MaterialViewSet
|
||
from apps.homework.models import Homework, HomeworkFile, HomeworkAssignmentFile
|
||
from .serializers import (
|
||
LessonSerializer,
|
||
LessonDetailSerializer,
|
||
LessonCreateSerializer,
|
||
LessonCancelSerializer,
|
||
LessonRescheduleSerializer,
|
||
LessonTemplateSerializer,
|
||
TimeSlotSerializer,
|
||
AvailabilitySerializer,
|
||
LessonCalendarSerializer,
|
||
LessonCalendarItemSerializer,
|
||
LessonFileSerializer,
|
||
LessonFileCreateSerializer,
|
||
LessonHomeworkSubmissionSerializer,
|
||
LessonHomeworkSubmissionCreateSerializer,
|
||
LessonHomeworkSubmissionGradeSerializer,
|
||
SubjectSerializer,
|
||
MentorSubjectSerializer,
|
||
MentorSubjectCreateSerializer,
|
||
)
|
||
from .permissions import IsLessonParticipant, IsMentorOrReadOnly
|
||
from apps.subscriptions.permissions import RequiresActiveSubscription
|
||
|
||
|
||
class LessonViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
ViewSet для управления занятиями.
|
||
|
||
list: Список всех занятий пользователя
|
||
retrieve: Детали конкретного занятия
|
||
create: Создать новое занятие
|
||
update: Обновить занятие
|
||
destroy: Удалить занятие
|
||
"""
|
||
queryset = Lesson.objects.all()
|
||
permission_classes = [IsAuthenticated, IsMentorOrReadOnly, RequiresActiveSubscription]
|
||
pagination_class = None # Отключаем пагинацию для получения всех занятий
|
||
|
||
def get_serializer_class(self):
|
||
"""Выбор сериализатора в зависимости от action."""
|
||
if self.action == 'create':
|
||
return LessonCreateSerializer
|
||
elif self.action == 'retrieve':
|
||
return LessonDetailSerializer
|
||
elif self.action == 'cancel':
|
||
return LessonCancelSerializer
|
||
elif self.action == 'reschedule':
|
||
return LessonRescheduleSerializer
|
||
return LessonSerializer
|
||
|
||
def get_queryset(self):
|
||
"""Фильтрация занятий в зависимости от роли пользователя."""
|
||
user = self.request.user
|
||
queryset = Lesson.objects.all()
|
||
|
||
# Админы видят все
|
||
if user.is_staff or user.is_superuser:
|
||
return queryset.select_related(
|
||
'mentor', 'client', 'client__user', 'template'
|
||
)
|
||
|
||
# Менторы видят свои занятия
|
||
if user.role == 'mentor':
|
||
queryset = queryset.filter(mentor=user)
|
||
# Фильтр по студенту (client_id — Client.id)
|
||
client_id = self.request.query_params.get('client_id')
|
||
if client_id:
|
||
try:
|
||
queryset = queryset.filter(client_id=int(client_id))
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# Студенты (клиенты) видят ТОЛЬКО свои занятия — не расписание ментора
|
||
elif getattr(user, 'role', None) == 'client':
|
||
queryset = queryset.filter(client__user_id=user.id)
|
||
|
||
# Родители видят занятия своих детей
|
||
elif user.role == 'parent':
|
||
try:
|
||
children_ids = user.parent_profile.children.values_list('id', flat=True)
|
||
queryset = queryset.filter(client_id__in=children_ids)
|
||
|
||
# Если указан child_id (user_id ребенка), фильтруем по конкретному ребенку
|
||
child_id = self.request.query_params.get('child_id')
|
||
if child_id:
|
||
try:
|
||
# child_id - это user_id ребенка, находим Client через User
|
||
from apps.users.models import Client
|
||
child_client = Client.objects.get(user_id=child_id)
|
||
# Проверяем, что это действительно ребенок родителя
|
||
if child_client.id in children_ids:
|
||
queryset = queryset.filter(client_id=child_client.id)
|
||
except (Client.DoesNotExist, ValueError):
|
||
# Если ребенок не найден или не принадлежит родителю, возвращаем пустой queryset
|
||
queryset = Lesson.objects.none()
|
||
except:
|
||
queryset = Lesson.objects.none()
|
||
|
||
else:
|
||
queryset = Lesson.objects.none()
|
||
|
||
# Фильтры из query params
|
||
status_filter = self.request.query_params.get('status')
|
||
if status_filter:
|
||
queryset = queryset.filter(status=status_filter)
|
||
|
||
# Поддержка date_from/date_to и start_date/end_date для совместимости
|
||
date_from = self.request.query_params.get('date_from') or self.request.query_params.get('start_date')
|
||
if date_from:
|
||
try:
|
||
date_from = datetime.fromisoformat(date_from)
|
||
queryset = queryset.filter(start_time__gte=date_from)
|
||
except:
|
||
pass
|
||
|
||
date_to = self.request.query_params.get('date_to') or self.request.query_params.get('end_date')
|
||
if date_to:
|
||
try:
|
||
date_to = datetime.fromisoformat(date_to)
|
||
queryset = queryset.filter(start_time__lte=date_to)
|
||
except:
|
||
pass
|
||
|
||
# Оптимизация: используем select_related и prefetch_related для избежания N+1 запросов
|
||
if self.action == 'list':
|
||
return queryset.select_related(
|
||
'mentor', 'client', 'client__user', 'template', 'subject', 'mentor_subject'
|
||
).prefetch_related(
|
||
'files', 'files__material', 'homeworks', 'homeworks__assigned_to'
|
||
).only(
|
||
'id', 'title', 'description', 'start_time', 'end_time', 'duration',
|
||
'status', 'price', 'meeting_url', 'mentor_id', 'client_id', 'group_id',
|
||
'template_id', 'subject_id', 'mentor_subject_id', 'subject_name',
|
||
'is_recurring', 'recurring_series_id', 'created_at', 'updated_at'
|
||
).order_by('-start_time')
|
||
if self.action == 'calendar':
|
||
return queryset.select_related(
|
||
'client', 'client__user', 'subject', 'mentor_subject'
|
||
).order_by('-start_time')
|
||
return queryset.select_related(
|
||
'mentor', 'client', 'client__user', 'template', 'subject', 'mentor_subject'
|
||
).prefetch_related(
|
||
'files', 'files__material', 'homeworks', 'homeworks__assigned_to'
|
||
).order_by('-start_time')
|
||
|
||
def _sync_homework_files(self, lesson: Lesson, homework: Homework, lesson_file_ids=None):
|
||
"""
|
||
Синхронизировать файлы урока (LessonFile) с «Файлами задания» ДЗ (HomeworkAssignmentFile).
|
||
Если передан lesson_file_ids — копируем только эти файлы урока (при завершении с фронта
|
||
передают только что загруженные в этой сессии, чтобы не тянуть старые).
|
||
"""
|
||
import os
|
||
from django.core.files.base import ContentFile
|
||
|
||
# Удаляем старые файлы задания (Файлы задания) для этого ДЗ
|
||
HomeworkAssignmentFile.objects.filter(homework=homework).delete()
|
||
HomeworkFile.objects.filter(homework=homework, file_type='assignment').delete()
|
||
|
||
qs = LessonFile.objects.filter(lesson_id=lesson.pk).select_related('material')
|
||
if lesson_file_ids is not None:
|
||
qs = qs.filter(pk__in=lesson_file_ids)
|
||
lesson_files = qs
|
||
|
||
for lf in lesson_files:
|
||
# Определяем источник файла: либо загруженный в LessonFile, либо файл материала
|
||
src_file_field = None
|
||
if lf.file:
|
||
src_file_field = lf.file
|
||
elif lf.material and lf.material.file:
|
||
src_file_field = lf.material.file
|
||
|
||
if not src_file_field:
|
||
continue
|
||
|
||
# Получаем имя файла
|
||
filename = lf.filename
|
||
if not filename and lf.material:
|
||
filename = lf.material.file_name or lf.material.title
|
||
if not filename:
|
||
filename = os.path.basename(src_file_field.name) if src_file_field.name else 'Файл'
|
||
|
||
# Получаем размер файла
|
||
file_size = lf.file_size
|
||
if file_size is None and lf.material and lf.material.file_size:
|
||
file_size = lf.material.file_size
|
||
if file_size is None and src_file_field:
|
||
try:
|
||
file_size = src_file_field.size
|
||
except (AttributeError, OSError):
|
||
file_size = 0
|
||
|
||
try:
|
||
src_file_field.open('rb')
|
||
file_content = src_file_field.read()
|
||
src_file_field.close()
|
||
|
||
new_filename = os.path.basename(filename)
|
||
file_obj = ContentFile(file_content, name=new_filename)
|
||
|
||
# Создаём HomeworkAssignmentFile — отображается в «Файлы задания»
|
||
HomeworkAssignmentFile.objects.create(
|
||
homework=homework,
|
||
file=file_obj,
|
||
filename=filename,
|
||
file_size=file_size or 0,
|
||
uploaded_by=lesson.mentor,
|
||
)
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(f'Ошибка копирования файла {filename} для ДЗ {homework.id}: {str(e)}')
|
||
continue
|
||
|
||
def perform_create(self, serializer):
|
||
"""Создание занятия."""
|
||
# Автоматически устанавливаем ментора
|
||
is_recurring = serializer.validated_data.get('is_recurring', False)
|
||
|
||
# Инвалидируем кеш дашборда после создания занятия
|
||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||
invalidate_dashboard_cache(self.request.user.id, self.request.user.role)
|
||
|
||
if is_recurring:
|
||
# Создаем серию повторяющихся занятий
|
||
lesson = self._create_recurring_lessons(serializer)
|
||
else:
|
||
# Обычное занятие
|
||
lesson = serializer.save(mentor=self.request.user)
|
||
|
||
# Создаем LiveKit комнату и токен сразу при создании урока
|
||
self._create_livekit_room_for_lesson(lesson)
|
||
|
||
# Если при создании указан homework_text, создаем ДЗ, но НЕ отправляем уведомление о ДЗ
|
||
# (только уведомление о создании занятия)
|
||
if lesson.homework_text:
|
||
from apps.homework.models import Homework
|
||
title = lesson.title or 'Домашнее задание'
|
||
homework_obj = Homework.objects.create(
|
||
title=title,
|
||
description=lesson.homework_text,
|
||
mentor=lesson.mentor,
|
||
lesson=lesson,
|
||
status='published',
|
||
)
|
||
# Назначаем ученика урока на это ДЗ
|
||
if getattr(lesson, 'client', None) and getattr(lesson.client, 'user', None):
|
||
homework_obj.assigned_to.add(lesson.client.user)
|
||
# Синхронизируем файлы
|
||
self._sync_homework_files(lesson, homework_obj)
|
||
|
||
# Отправляем уведомление о новом занятии (только одно уведомление)
|
||
from apps.notifications.services import NotificationService
|
||
NotificationService.send_lesson_created(lesson)
|
||
|
||
return lesson
|
||
|
||
def _create_livekit_room_for_lesson(self, lesson):
|
||
"""Создает LiveKit комнату и токен для урока."""
|
||
try:
|
||
from apps.video.livekit_service import LiveKitService
|
||
from apps.video.models import VideoRoom
|
||
|
||
# Генерируем уникальное название комнаты
|
||
room_name = LiveKitService.generate_room_name()
|
||
|
||
# Создаем VideoRoom запись
|
||
client_user = lesson.client.user if hasattr(lesson.client, 'user') else lesson.client
|
||
video_room = VideoRoom.objects.create(
|
||
lesson=lesson,
|
||
mentor=lesson.mentor,
|
||
client=client_user,
|
||
room_id=room_name,
|
||
is_recording=True,
|
||
max_participants=10 if lesson.group else 2
|
||
)
|
||
|
||
# Генерируем токен для ментора (действителен 24 часа)
|
||
mentor_token = LiveKitService.generate_access_token(
|
||
room_name=room_name,
|
||
participant_name=lesson.mentor.get_full_name(),
|
||
participant_identity=str(lesson.mentor.pk),
|
||
is_admin=True,
|
||
expires_in_minutes=1440 # 24 часа
|
||
)
|
||
|
||
# Сохраняем данные в урок
|
||
lesson.livekit_room_name = room_name
|
||
lesson.livekit_access_token = mentor_token
|
||
lesson.save(update_fields=['livekit_room_name', 'livekit_access_token'])
|
||
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(f'Ошибка создания LiveKit комнаты для урока {lesson.id}: {str(e)}')
|
||
|
||
def _create_recurring_lessons(self, serializer):
|
||
"""
|
||
Создает серию повторяющихся занятий на 12 недель вперед.
|
||
"""
|
||
# Генерируем уникальный ID для серии
|
||
series_id = uuid.uuid4()
|
||
|
||
# Получаем данные из сериализатора
|
||
start_time = serializer.validated_data['start_time']
|
||
duration = serializer.validated_data.get('duration', 60)
|
||
mentor = self.request.user
|
||
|
||
# Создаем первое занятие (родительское)
|
||
first_lesson_data = serializer.validated_data.copy()
|
||
first_lesson_data['mentor'] = mentor
|
||
first_lesson_data['is_recurring'] = True
|
||
first_lesson_data['recurring_series_id'] = series_id
|
||
first_lesson_data['parent_lesson'] = None # Первое занятие не имеет родителя
|
||
|
||
# Рассчитываем end_time для первого занятия
|
||
first_lesson_data['end_time'] = start_time + timedelta(minutes=duration)
|
||
|
||
first_lesson = Lesson.objects.create(**first_lesson_data)
|
||
|
||
# Создаем повторяющиеся занятия на 12 недель вперед
|
||
lessons_to_create = []
|
||
for week in range(1, 12): # 11 дополнительных занятий (всего 12)
|
||
new_start_time = start_time + timedelta(weeks=week)
|
||
new_end_time = new_start_time + timedelta(minutes=duration)
|
||
|
||
lesson_data = {
|
||
'mentor': mentor,
|
||
'client': serializer.validated_data['client'],
|
||
'group': serializer.validated_data.get('group'),
|
||
'start_time': new_start_time,
|
||
'end_time': new_end_time,
|
||
'duration': duration,
|
||
'title': serializer.validated_data['title'],
|
||
'description': serializer.validated_data.get('description', ''),
|
||
'subject': serializer.validated_data.get('subject'), # Экземпляр Subject или None
|
||
'mentor_subject': serializer.validated_data.get('mentor_subject'), # Экземпляр MentorSubject или None
|
||
'subject_name': serializer.validated_data.get('subject_name', ''),
|
||
'template': serializer.validated_data.get('template'),
|
||
'price': serializer.validated_data.get('price'),
|
||
'is_recurring': True,
|
||
'recurring_series_id': series_id,
|
||
'parent_lesson': first_lesson,
|
||
}
|
||
lessons_to_create.append(Lesson(**lesson_data))
|
||
|
||
# Массовое создание для оптимизации
|
||
Lesson.objects.bulk_create(lessons_to_create)
|
||
|
||
return first_lesson
|
||
|
||
def destroy(self, request, *args, **kwargs):
|
||
"""
|
||
Переопределяем destroy для получения параметра delete_all_future из тела запроса.
|
||
"""
|
||
instance = self.get_object()
|
||
|
||
# Получаем параметр из тела запроса
|
||
delete_all_future = request.data.get('delete_all_future', False) if hasattr(request, 'data') else False
|
||
|
||
# Сохраняем параметр в request для использования в perform_destroy
|
||
request._delete_all_future = delete_all_future
|
||
|
||
return super().destroy(request, *args, **kwargs)
|
||
|
||
def perform_destroy(self, instance):
|
||
"""
|
||
Удаление занятия.
|
||
Если это повторяющееся занятие и delete_all_future=True, удаляем все будущие занятия из серии.
|
||
Если delete_all_future=False, удаляем только текущее занятие.
|
||
Прошедшие занятия (завершенные или с прошедшим временем) не удаляются.
|
||
"""
|
||
# Сохраняем данные для инвалидации кеша перед удалением
|
||
mentor_id = instance.mentor.id if instance.mentor else None
|
||
client_user_id = instance.client.user.id if instance.client and instance.client.user else None
|
||
|
||
now = timezone.now()
|
||
|
||
# Получаем параметр из request
|
||
delete_all_future = getattr(self.request, '_delete_all_future', False)
|
||
|
||
# Импортируем сервис уведомлений
|
||
from apps.notifications.services import NotificationService
|
||
|
||
# Если это повторяющееся занятие
|
||
if instance.is_recurring and instance.recurring_series_id:
|
||
if delete_all_future:
|
||
# Удаляем все будущие занятия из серии (которые еще не начались)
|
||
future_lessons = Lesson.objects.filter(
|
||
recurring_series_id=instance.recurring_series_id,
|
||
start_time__gt=now, # Только будущие занятия
|
||
start_time__gte=instance.start_time # Начиная с текущего занятия
|
||
)
|
||
|
||
# Отправляем ОДНО уведомление об удалении цепочки постоянных занятий
|
||
# (используем первое занятие из серии для контекста)
|
||
if instance.client and instance.client.user:
|
||
NotificationService.send_lesson_deleted(instance, is_recurring_series=True)
|
||
|
||
future_lessons.delete()
|
||
|
||
# Удаляем само занятие, если оно еще не началось
|
||
if instance.start_time > now:
|
||
instance.delete()
|
||
# Если занятие уже прошло - не удаляем его, но будущие уже удалены
|
||
else:
|
||
# Удаляем только текущее занятие, если оно еще не началось
|
||
if instance.start_time > now:
|
||
NotificationService.send_lesson_deleted(instance)
|
||
instance.delete()
|
||
# Инвалидируем кеш дашборда после удаления
|
||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||
if mentor_id:
|
||
invalidate_dashboard_cache(mentor_id, 'mentor')
|
||
if client_user_id:
|
||
invalidate_dashboard_cache(client_user_id, 'client')
|
||
else:
|
||
# Если занятие уже началось или прошло - не удаляем
|
||
from rest_framework.exceptions import ValidationError
|
||
raise ValidationError(
|
||
'Нельзя удалить занятие, которое уже началось или прошло. '
|
||
'Удалите его вручную, если необходимо.'
|
||
)
|
||
else:
|
||
# Обычное занятие - удаляем только если еще не началось
|
||
if instance.start_time > now:
|
||
NotificationService.send_lesson_deleted(instance)
|
||
instance.delete()
|
||
# Инвалидируем кеш дашборда после удаления
|
||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||
if mentor_id:
|
||
invalidate_dashboard_cache(mentor_id, 'mentor')
|
||
if client_user_id:
|
||
invalidate_dashboard_cache(client_user_id, 'client')
|
||
else:
|
||
from rest_framework.exceptions import ValidationError
|
||
raise ValidationError(
|
||
'Нельзя удалить занятие, которое уже началось или прошло. '
|
||
'Удалите его вручную, если необходимо.'
|
||
)
|
||
|
||
def perform_update(self, serializer):
|
||
"""
|
||
Обновление занятия (в том числе homework_text при редактировании завершенного урока).
|
||
После сохранения синхронизируем текст ДЗ с моделью Homework,
|
||
чтобы задания отображались на странице /homework.
|
||
"""
|
||
lesson = serializer.save()
|
||
|
||
# Инвалидируем кеш дашборда после обновления занятия
|
||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||
if lesson.mentor:
|
||
invalidate_dashboard_cache(lesson.mentor.id, 'mentor')
|
||
if lesson.client and lesson.client.user:
|
||
invalidate_dashboard_cache(lesson.client.user.id, 'client')
|
||
|
||
# Ищем существующее ДЗ, привязанное к этому уроку с оптимизацией
|
||
existing_hw = Homework.objects.filter(lesson=lesson, mentor=lesson.mentor).select_related('mentor', 'lesson').first()
|
||
|
||
# Проверяем, было ли ДЗ до обновления (чтобы не отправлять уведомление при первом создании)
|
||
had_homework_before = existing_hw is not None
|
||
|
||
# Проверяем наличие файлов урока по свежему запросу (не prefetch)
|
||
has_lesson_files = LessonFile.objects.filter(lesson_id=lesson.pk).exists()
|
||
# ДЗ создается, если есть текст или есть файлы
|
||
has_homework = bool(lesson.homework_text and lesson.homework_text.strip()) or has_lesson_files
|
||
|
||
if has_homework:
|
||
# Есть текст ДЗ или файлы – создаём или обновляем опубликованное задание
|
||
title = lesson.title or 'Домашнее задание'
|
||
description = (lesson.homework_text or '').strip() or '' # описание не обязательно
|
||
|
||
if existing_hw:
|
||
existing_hw.title = title
|
||
existing_hw.description = description
|
||
existing_hw.status = 'published'
|
||
existing_hw.lesson = lesson
|
||
existing_hw.save()
|
||
homework_obj = existing_hw
|
||
else:
|
||
# Создаем новое ДЗ (только при обновлении, не при создании)
|
||
homework_obj = Homework.objects.create(
|
||
title=title,
|
||
description=description,
|
||
mentor=lesson.mentor,
|
||
lesson=lesson,
|
||
status='published',
|
||
)
|
||
# Назначаем ученика урока на это ДЗ
|
||
if getattr(lesson, 'client', None) and getattr(lesson.client, 'user', None):
|
||
homework_obj.assigned_to.add(lesson.client.user)
|
||
|
||
# Отправляем уведомление студенту о новом ДЗ только если ДЗ было создано при обновлении
|
||
# (не при создании занятия, чтобы избежать дублирования)
|
||
if not had_homework_before:
|
||
from apps.notifications.services import NotificationService
|
||
NotificationService.send_homework_notification(homework_obj, 'homework_assigned')
|
||
|
||
# Синхронизируем прикрепленные к уроку материалы с файлами ДЗ
|
||
self._sync_homework_files(lesson, homework_obj)
|
||
else:
|
||
# Нет текста и нет файлов – если было задание, отправляем его в архив
|
||
if existing_hw:
|
||
existing_hw.status = 'archived'
|
||
existing_hw.save(update_fields=['status'])
|
||
|
||
def create(self, request, *args, **kwargs):
|
||
"""Переопределяем create для возврата детального сериализатора."""
|
||
serializer = self.get_serializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
lesson = self.perform_create(serializer)
|
||
|
||
# Возвращаем детальный сериализатор
|
||
detail_serializer = LessonDetailSerializer(lesson)
|
||
headers = self.get_success_headers(detail_serializer.data)
|
||
return Response(detail_serializer.data, status=201, headers=headers)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def complete(self, request, pk=None):
|
||
"""
|
||
Завершить занятие или обновить обратную связь.
|
||
|
||
POST /api/schedule/lessons/{id}/complete/
|
||
Body: {
|
||
"notes": "...",
|
||
"mentor_grade": 85,
|
||
"school_grade": 80
|
||
}
|
||
"""
|
||
lesson = self.get_object()
|
||
|
||
# Нельзя завершить занятие, которое ещё не началось (если оно не было завершено ранее)
|
||
if lesson.status != 'completed' and lesson.start_time and lesson.start_time > timezone.now():
|
||
return Response({
|
||
'success': False,
|
||
'error': {
|
||
'message': 'Нельзя завершить занятие, которое ещё не началось'
|
||
}
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# Если занятие отменено - нельзя завершить
|
||
if lesson.status == 'cancelled':
|
||
return Response({
|
||
'success': False,
|
||
'error': {
|
||
'message': 'Нельзя завершить отмененное занятие'
|
||
}
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# Обновляем статус (если еще не завершено)
|
||
if lesson.status != 'completed':
|
||
lesson.status = 'completed'
|
||
# Сохраняем фактическое время завершения
|
||
lesson.completed_at = timezone.now()
|
||
|
||
# Сохраняем заметки, домашнее задание и оценки (можно обновлять для уже завершенных)
|
||
notes = request.data.get('notes', '')
|
||
if notes or notes == '': # Разрешаем пустые заметки
|
||
lesson.mentor_notes = notes
|
||
|
||
homework_text = request.data.get('homework_text')
|
||
if homework_text is not None:
|
||
# Пустая строка очищает домашнее задание
|
||
lesson.homework_text = homework_text
|
||
|
||
mentor_grade = request.data.get('mentor_grade')
|
||
if mentor_grade is not None:
|
||
lesson.mentor_grade = int(mentor_grade)
|
||
|
||
school_grade = request.data.get('school_grade')
|
||
if school_grade is not None:
|
||
lesson.school_grade = int(school_grade)
|
||
|
||
# Сохраняем цену, если она передана
|
||
# Если цена не передана, сохраняем существующую цену (не перезаписываем)
|
||
price = request.data.get('price')
|
||
if price is not None:
|
||
try:
|
||
lesson.price = float(price)
|
||
except (ValueError, TypeError):
|
||
pass # Если цена невалидна, оставляем существующую
|
||
|
||
# Важно: сохраняем занятие, чтобы цена не потерялась
|
||
lesson.save()
|
||
|
||
# Инвалидируем кеш дашборда после завершения занятия
|
||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||
if lesson.mentor:
|
||
invalidate_dashboard_cache(lesson.mentor.id, 'mentor')
|
||
if lesson.client and lesson.client.user:
|
||
invalidate_dashboard_cache(lesson.client.user.id, 'client')
|
||
|
||
# Создаём/обновляем ДЗ только по данным текущего запроса, чтобы не воссоздавать
|
||
# удалённые ДЗ из-за старых lesson.homework_text или файлов урока.
|
||
existing_hw = Homework.objects.filter(lesson=lesson, mentor=lesson.mentor).select_related('mentor', 'lesson').first()
|
||
|
||
request_homework_text = request.data.get('homework_text')
|
||
request_has_text = request_homework_text is not None and bool((request_homework_text or '').strip())
|
||
lesson_file_ids_raw = request.data.get('lesson_file_ids')
|
||
request_has_file_ids = isinstance(lesson_file_ids_raw, list) and len(lesson_file_ids_raw or []) > 0
|
||
has_homework_files_param = request.data.get('has_homework_files') in (True, 'true', 1)
|
||
request_has_files = has_homework_files_param or request_has_file_ids
|
||
|
||
# Проверяем флаг "заполнить позже"
|
||
homework_fill_later = request.data.get('homework_fill_later') in (True, 'true', 1, 'True')
|
||
|
||
has_homework = request_has_text or request_has_files or homework_fill_later
|
||
|
||
homework_id = None
|
||
if has_homework:
|
||
title = lesson.title or 'Домашнее задание'
|
||
description = (lesson.homework_text or '').strip() or ''
|
||
|
||
# Определяем статус и fill_later флаг
|
||
if homework_fill_later:
|
||
hw_status = 'draft'
|
||
hw_fill_later = True
|
||
else:
|
||
hw_status = 'published'
|
||
hw_fill_later = False
|
||
|
||
if existing_hw:
|
||
existing_hw.title = title
|
||
existing_hw.description = description
|
||
existing_hw.status = hw_status
|
||
existing_hw.fill_later = hw_fill_later
|
||
existing_hw.lesson = lesson
|
||
existing_hw.save()
|
||
homework_obj = existing_hw
|
||
else:
|
||
homework_obj = Homework.objects.create(
|
||
title=title,
|
||
description=description,
|
||
mentor=lesson.mentor,
|
||
lesson=lesson,
|
||
status=hw_status,
|
||
fill_later=hw_fill_later,
|
||
)
|
||
homework_id = homework_obj.id
|
||
|
||
# Назначаем ученика урока на это ДЗ (client может быть не подгружен — подгружаем)
|
||
lesson_with_client = Lesson.objects.select_related('client', 'client__user').filter(pk=lesson.pk).first()
|
||
client_user = None
|
||
if lesson_with_client and lesson_with_client.client_id:
|
||
if hasattr(lesson_with_client.client, 'user') and lesson_with_client.client.user_id:
|
||
client_user = lesson_with_client.client.user
|
||
if client_user:
|
||
homework_obj.assigned_to.add(client_user)
|
||
# Уведомление отправляем ТОЛЬКО если ДЗ опубликовано (не fill_later)
|
||
if not hw_fill_later:
|
||
from apps.notifications.services import NotificationService
|
||
NotificationService.send_homework_notification(homework_obj, 'homework_assigned')
|
||
|
||
# Синхронизируем прикрепленные к уроку материалы с файлами ДЗ
|
||
lesson_file_ids = None
|
||
if isinstance(lesson_file_ids_raw, list) and lesson_file_ids_raw:
|
||
lesson_file_ids = [int(x) for x in lesson_file_ids_raw if x is not None]
|
||
self._sync_homework_files(lesson, homework_obj, lesson_file_ids=lesson_file_ids)
|
||
else:
|
||
# Нет текста и нет файлов – если было задание, отправляем его в архив
|
||
if existing_hw:
|
||
existing_hw.status = 'archived'
|
||
existing_hw.save(update_fields=['status'])
|
||
|
||
response_payload = {
|
||
'success': True,
|
||
'message': 'Занятие завершено' if lesson.status == 'completed' else 'Обратная связь обновлена',
|
||
'data': LessonDetailSerializer(lesson).data
|
||
}
|
||
if homework_id is not None:
|
||
response_payload['homework_id'] = homework_id
|
||
response_payload['homework_created'] = True
|
||
return Response(response_payload)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def cancel(self, request, pk=None):
|
||
"""
|
||
Отменить занятие.
|
||
|
||
POST /api/schedule/lessons/{id}/cancel/
|
||
Body: {"cancellation_reason": "причина"}
|
||
"""
|
||
lesson = self.get_object()
|
||
|
||
if not lesson.can_be_cancelled:
|
||
return Response({
|
||
'success': False,
|
||
'error': {
|
||
'message': 'Это занятие нельзя отменить'
|
||
}
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
serializer = self.get_serializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
|
||
try:
|
||
lesson.cancel(
|
||
user=request.user,
|
||
reason=serializer.validated_data.get('cancellation_reason', '')
|
||
)
|
||
|
||
# Отправляем уведомление об отмене
|
||
from apps.notifications.services import NotificationService
|
||
NotificationService.send_lesson_cancelled(lesson)
|
||
|
||
return Response({
|
||
'success': True,
|
||
'message': 'Занятие отменено',
|
||
'data': LessonDetailSerializer(lesson).data
|
||
})
|
||
except ValueError as e:
|
||
return Response({
|
||
'success': False,
|
||
'error': {
|
||
'message': str(e)
|
||
}
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def reschedule(self, request, pk=None):
|
||
"""
|
||
Перенести занятие на новое время.
|
||
|
||
POST /api/schedule/lessons/{id}/reschedule/
|
||
Body: {"new_start_time": "2024-01-15T14:00:00Z"}
|
||
"""
|
||
lesson = self.get_object()
|
||
|
||
if not lesson.can_be_rescheduled:
|
||
return Response({
|
||
'success': False,
|
||
'error': {
|
||
'message': 'Это занятие нельзя перенести'
|
||
}
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
serializer = self.get_serializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
|
||
try:
|
||
new_lesson = lesson.reschedule(
|
||
new_start_time=serializer.validated_data['new_start_time']
|
||
)
|
||
|
||
return Response({
|
||
'success': True,
|
||
'message': 'Занятие перенесено',
|
||
'data': LessonDetailSerializer(new_lesson).data
|
||
})
|
||
except ValueError as e:
|
||
return Response({
|
||
'success': False,
|
||
'error': {
|
||
'message': str(e)
|
||
}
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def copy(self, request, pk=None):
|
||
"""
|
||
Копировать занятие.
|
||
|
||
POST /api/schedule/lessons/{id}/copy/
|
||
Body: {"new_start_time": "2024-01-15T14:00:00Z"}
|
||
"""
|
||
lesson = self.get_object()
|
||
|
||
new_start_time_str = request.data.get('new_start_time')
|
||
if not new_start_time_str:
|
||
return Response({
|
||
'success': False,
|
||
'error': {
|
||
'message': 'Укажите новое время начала (new_start_time)'
|
||
}
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
try:
|
||
new_start_time = datetime.fromisoformat(new_start_time_str.replace('Z', '+00:00'))
|
||
except:
|
||
return Response({
|
||
'success': False,
|
||
'error': {
|
||
'message': 'Неверный формат даты'
|
||
}
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
if new_start_time <= timezone.now():
|
||
return Response({
|
||
'success': False,
|
||
'error': {
|
||
'message': 'Новое время должно быть в будущем'
|
||
}
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# Создаем копию
|
||
new_lesson = Lesson.objects.create(
|
||
mentor=lesson.mentor,
|
||
client=lesson.client,
|
||
start_time=new_start_time,
|
||
duration=lesson.duration,
|
||
title=lesson.title,
|
||
description=lesson.description,
|
||
subject=lesson.subject,
|
||
template=lesson.template,
|
||
meeting_url=lesson.meeting_url,
|
||
)
|
||
|
||
return Response({
|
||
'success': True,
|
||
'message': 'Занятие скопировано',
|
||
'data': LessonDetailSerializer(new_lesson).data
|
||
})
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def export_ical(self, request):
|
||
"""
|
||
Экспорт расписания в формат iCal.
|
||
|
||
GET /api/schedule/lessons/export_ical/?start_date=2024-01-01&end_date=2024-12-31
|
||
"""
|
||
from .export_service import ScheduleExportService
|
||
|
||
user = request.user
|
||
start_date = request.query_params.get('start_date')
|
||
end_date = request.query_params.get('end_date')
|
||
|
||
# Получаем занятия пользователя
|
||
if user.role == 'mentor':
|
||
lessons = Lesson.objects.filter(mentor=user)
|
||
elif user.role == 'client':
|
||
lessons = Lesson.objects.filter(client__user=user)
|
||
else:
|
||
lessons = Lesson.objects.none()
|
||
|
||
# Фильтрация по датам
|
||
if start_date:
|
||
try:
|
||
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
||
lessons = lessons.filter(start_time__gte=start_dt)
|
||
except ValueError:
|
||
pass
|
||
|
||
if end_date:
|
||
try:
|
||
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||
lessons = lessons.filter(start_time__lte=end_dt)
|
||
except ValueError:
|
||
pass
|
||
|
||
# Экспортируем в iCal
|
||
return ScheduleExportService.export_to_ical(lessons, user=user)
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def upcoming(self, request):
|
||
"""
|
||
Получить предстоящие занятия.
|
||
|
||
GET /api/schedule/lessons/upcoming/
|
||
"""
|
||
queryset = self.get_queryset().filter(
|
||
status='scheduled',
|
||
start_time__gte=timezone.now()
|
||
)
|
||
|
||
# Оптимизация: для upcoming используем only() для ограничения полей
|
||
queryset = queryset.only(
|
||
'id', 'title', 'start_time', 'end_time', 'duration', 'mentor_id',
|
||
'client_id', 'group_id', 'subject_id', 'status', 'meeting_url',
|
||
'created_at'
|
||
).order_by('start_time')[:10]
|
||
|
||
serializer = self.get_serializer(queryset, many=True)
|
||
return Response({
|
||
'success': True,
|
||
'data': serializer.data
|
||
})
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def calendar(self, request):
|
||
"""
|
||
Получить занятия для календаря.
|
||
|
||
GET /api/schedule/lessons/calendar/?start_date=2024-01-01&end_date=2024-01-31
|
||
|
||
Студент видит только свои занятия. Ментор — свои. Родитель — занятия детей.
|
||
"""
|
||
serializer = LessonCalendarSerializer(data=request.query_params)
|
||
serializer.is_valid(raise_exception=True)
|
||
|
||
data = serializer.validated_data
|
||
queryset = self.get_queryset().filter(
|
||
start_time__date__gte=data['start_date'],
|
||
start_time__date__lte=data['end_date']
|
||
)
|
||
# Доп. защита: студент никогда не должен видеть чужие занятия
|
||
if getattr(request.user, 'role', None) == 'client':
|
||
queryset = queryset.filter(client__user_id=request.user.id)
|
||
if data.get('status'):
|
||
queryset = queryset.filter(status=data['status'])
|
||
lessons = LessonCalendarItemSerializer(
|
||
queryset, many=True, context={'request': request}
|
||
).data
|
||
return Response({
|
||
'success': True,
|
||
'data': {
|
||
'start_date': data['start_date'],
|
||
'end_date': data['end_date'],
|
||
'lessons': lessons,
|
||
'total': len(lessons)
|
||
}
|
||
})
|
||
|
||
|
||
class LessonTemplateViewSet(viewsets.ModelViewSet):
|
||
"""ViewSet для управления шаблонами занятий."""
|
||
|
||
queryset = LessonTemplate.objects.all()
|
||
serializer_class = LessonTemplateSerializer
|
||
permission_classes = [IsAuthenticated, IsMentorOrReadOnly]
|
||
|
||
def get_queryset(self):
|
||
"""Фильтрация шаблонов."""
|
||
user = self.request.user
|
||
|
||
if user.is_staff or user.is_superuser:
|
||
queryset = LessonTemplate.objects.all()
|
||
elif user.role == 'mentor':
|
||
queryset = LessonTemplate.objects.filter(mentor=user)
|
||
else:
|
||
return LessonTemplate.objects.none()
|
||
|
||
# Оптимизация: используем select_related для избежания N+1
|
||
queryset = queryset.select_related('mentor')
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'mentor_id', 'title', 'description', 'subject_id',
|
||
'duration', 'is_active', 'meeting_url', 'color', 'created_at', 'updated_at'
|
||
)
|
||
|
||
return queryset
|
||
|
||
def perform_create(self, serializer):
|
||
"""Создание шаблона (ментор автоматически назначается)."""
|
||
serializer.save(mentor=self.request.user)
|
||
|
||
|
||
class TimeSlotViewSet(viewsets.ModelViewSet):
|
||
"""ViewSet для управления временными слотами."""
|
||
|
||
queryset = TimeSlot.objects.all()
|
||
serializer_class = TimeSlotSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def get_queryset(self):
|
||
"""Фильтрация слотов."""
|
||
user = self.request.user
|
||
|
||
if user.is_staff or user.is_superuser:
|
||
queryset = TimeSlot.objects.all()
|
||
elif user.role == 'mentor':
|
||
queryset = TimeSlot.objects.filter(mentor=user)
|
||
elif user.role == 'client':
|
||
# Клиенты видят доступные слоты своих менторов
|
||
try:
|
||
mentor_ids = user.client_profile.mentors.values_list('id', flat=True)
|
||
queryset = TimeSlot.objects.filter(
|
||
mentor_id__in=mentor_ids,
|
||
is_available=True,
|
||
is_booked=False,
|
||
start_time__gte=timezone.now()
|
||
)
|
||
except:
|
||
return TimeSlot.objects.none()
|
||
else:
|
||
return TimeSlot.objects.none()
|
||
|
||
# Оптимизация: используем select_related для избежания N+1
|
||
queryset = queryset.select_related('mentor')
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'mentor_id', 'start_time', 'end_time', 'duration',
|
||
'is_available', 'is_booked', 'created_at', 'updated_at'
|
||
)
|
||
|
||
return queryset
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def available(self, request):
|
||
"""
|
||
Получить доступные слоты ментора.
|
||
|
||
GET /api/schedule/time-slots/available/?mentor_id=1&date_from=2024-01-01&date_to=2024-01-31
|
||
"""
|
||
mentor_id = request.query_params.get('mentor_id')
|
||
if not mentor_id:
|
||
return Response({
|
||
'success': False,
|
||
'error': {'message': 'Укажите mentor_id'}
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
queryset = TimeSlot.objects.filter(
|
||
mentor_id=mentor_id,
|
||
is_available=True,
|
||
is_booked=False,
|
||
start_time__gte=timezone.now()
|
||
).select_related('mentor')
|
||
|
||
# Фильтры по дате
|
||
date_from = request.query_params.get('date_from')
|
||
if date_from:
|
||
queryset = queryset.filter(start_time__date__gte=date_from)
|
||
|
||
date_to = request.query_params.get('date_to')
|
||
if date_to:
|
||
queryset = queryset.filter(start_time__date__lte=date_to)
|
||
|
||
# Оптимизация: используем only() для ограничения полей
|
||
queryset = queryset.only(
|
||
'id', 'mentor_id', 'start_time', 'end_time', 'duration',
|
||
'is_available', 'is_booked', 'created_at', 'updated_at'
|
||
)
|
||
|
||
serializer = self.get_serializer(queryset, many=True)
|
||
return Response({
|
||
'success': True,
|
||
'data': serializer.data
|
||
})
|
||
|
||
|
||
class AvailabilityViewSet(viewsets.ModelViewSet):
|
||
"""ViewSet для управления доступностью."""
|
||
|
||
queryset = Availability.objects.all()
|
||
serializer_class = AvailabilitySerializer
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def get_queryset(self):
|
||
"""Фильтрация доступности."""
|
||
user = self.request.user
|
||
|
||
if user.is_staff or user.is_superuser:
|
||
queryset = Availability.objects.all()
|
||
elif user.role == 'mentor':
|
||
queryset = Availability.objects.filter(mentor=user)
|
||
elif user.role == 'client':
|
||
# Клиенты видят доступность своих менторов
|
||
try:
|
||
mentor_ids = user.client_profile.mentors.values_list('id', flat=True)
|
||
queryset = Availability.objects.filter(
|
||
mentor_id__in=mentor_ids,
|
||
is_active=True
|
||
)
|
||
except:
|
||
return Availability.objects.none()
|
||
else:
|
||
return Availability.objects.none()
|
||
|
||
# Оптимизация: используем select_related для избежания N+1
|
||
queryset = queryset.select_related('mentor')
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'mentor_id', 'day_of_week', 'start_time', 'end_time',
|
||
'is_active', 'created_at', 'updated_at'
|
||
)
|
||
|
||
return queryset
|
||
|
||
def perform_create(self, serializer):
|
||
"""Создание доступности (только для менторов)."""
|
||
if self.request.user.role != 'mentor':
|
||
from rest_framework.exceptions import PermissionDenied
|
||
raise PermissionDenied('Только менторы могут создавать доступность')
|
||
|
||
serializer.save(mentor=self.request.user)
|
||
|
||
|
||
class LessonFileViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
ViewSet для управления файлами уроков.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
serializer_class = LessonFileSerializer
|
||
|
||
def get_serializer_class(self):
|
||
"""Выбор сериализатора в зависимости от action."""
|
||
if self.action == 'create':
|
||
return LessonFileCreateSerializer
|
||
return LessonFileSerializer
|
||
|
||
def get_queryset(self):
|
||
"""Получение файлов уроков."""
|
||
user = self.request.user
|
||
lesson_id = self.request.query_params.get('lesson')
|
||
|
||
queryset = LessonFile.objects.all()
|
||
|
||
if lesson_id:
|
||
queryset = queryset.filter(lesson_id=lesson_id)
|
||
|
||
# Фильтруем по доступу: ментор урока или студент урока
|
||
if user.role == 'mentor':
|
||
queryset = queryset.filter(lesson__mentor=user)
|
||
elif user.role == 'client':
|
||
# Для клиентов показываем только файлы их уроков
|
||
queryset = queryset.filter(lesson__client__user=user)
|
||
else:
|
||
# Для других ролей - пустой queryset
|
||
queryset = queryset.none()
|
||
|
||
queryset = queryset.select_related('lesson', 'lesson__mentor', 'lesson__client', 'material', 'uploaded_by')
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'lesson_id', 'material_id', 'uploaded_by_id', 'filename',
|
||
'file_size', 'source', 'created_at'
|
||
)
|
||
|
||
return queryset
|
||
|
||
def perform_create(self, serializer):
|
||
"""Создание файла урока.
|
||
|
||
Логика:
|
||
- если передан material: просто связываем существующий материал с уроком
|
||
и выдаем доступ ученику к этому материалу;
|
||
- если загружается file: создаем новый Material, выдаем доступ ученику
|
||
и связываем его с LessonFile.
|
||
"""
|
||
lesson_id = self.request.data.get('lesson')
|
||
if not lesson_id:
|
||
from rest_framework.exceptions import ValidationError
|
||
raise ValidationError({'lesson': 'Необходимо указать урок'})
|
||
|
||
try:
|
||
lesson = Lesson.objects.get(id=lesson_id)
|
||
except Lesson.DoesNotExist:
|
||
from rest_framework.exceptions import NotFound
|
||
raise NotFound('Урок не найден')
|
||
|
||
# Проверяем доступ: только ментор урока может добавлять файлы
|
||
if lesson.mentor != self.request.user:
|
||
from rest_framework.exceptions import PermissionDenied
|
||
raise PermissionDenied('Только ментор урока может добавлять файлы')
|
||
|
||
file = self.request.FILES.get('file')
|
||
material_id = self.request.data.get('material')
|
||
|
||
# Если выбран существующий материал
|
||
if material_id and not file:
|
||
from rest_framework.exceptions import NotFound
|
||
try:
|
||
material = Material.objects.get(id=material_id, owner=self.request.user, is_deleted=False)
|
||
except Material.DoesNotExist:
|
||
raise NotFound('Материал не найден')
|
||
|
||
# Выдаем доступ ученику к материалу
|
||
student_user = lesson.client.user
|
||
material.shared_with.add(student_user)
|
||
|
||
# Создаем LessonFile, ссылающийся на этот материал
|
||
serializer.save(
|
||
uploaded_by=self.request.user,
|
||
lesson=lesson,
|
||
material=material,
|
||
source='material',
|
||
filename=serializer.validated_data.get('filename') or material.title,
|
||
file_size=serializer.validated_data.get('file_size') or material.file_size,
|
||
)
|
||
return
|
||
|
||
# Если загружается новый файл — создаем новый Material
|
||
if file:
|
||
# Создаем Material через его ViewSet/serializer, чтобы соблюсти валидацию
|
||
material_data = {
|
||
'title': serializer.validated_data.get('filename') or file.name,
|
||
'description': serializer.validated_data.get('description', ''),
|
||
'material_type': 'document',
|
||
'access_type': 'private',
|
||
}
|
||
# Создаем материал как владелец-ментор
|
||
material = Material.objects.create(
|
||
owner=self.request.user,
|
||
title=material_data['title'],
|
||
description=material_data['description'],
|
||
file=file,
|
||
file_name=file.name,
|
||
file_size=file.size,
|
||
file_type=file.content_type or '',
|
||
material_type='document',
|
||
access_type='private',
|
||
)
|
||
# Выдаем доступ ученику
|
||
student_user = lesson.client.user
|
||
material.shared_with.add(student_user)
|
||
|
||
# Создаем LessonFile, связанный с новым материалом
|
||
serializer.save(
|
||
uploaded_by=self.request.user,
|
||
lesson=lesson,
|
||
material=material,
|
||
source='uploaded',
|
||
filename=serializer.validated_data.get('filename') or file.name,
|
||
file_size=file.size,
|
||
)
|
||
return
|
||
|
||
# На всякий случай, если сюда дошли без файла и материала
|
||
serializer.save(uploaded_by=self.request.user, lesson=lesson)
|
||
|
||
|
||
class LessonHomeworkSubmissionViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
ViewSet для управления ответами на ДЗ по уроку.
|
||
|
||
list: Список ответов на ДЗ
|
||
create: Сдать ДЗ (для ученика)
|
||
retrieve: Получить ответ на ДЗ
|
||
grade: Выставить оценку (для ментора)
|
||
return_for_revision: Вернуть на доработку (для ментора)
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def get_queryset(self):
|
||
"""Получение ответов на ДЗ."""
|
||
user = self.request.user
|
||
lesson_id = self.request.query_params.get('lesson_id')
|
||
|
||
queryset = LessonHomeworkSubmission.objects.all().select_related(
|
||
'lesson', 'lesson__mentor', 'lesson__client', 'student', 'checked_by'
|
||
)
|
||
|
||
# Фильтр по уроку
|
||
if lesson_id:
|
||
queryset = queryset.filter(lesson_id=lesson_id)
|
||
|
||
# Ментор видит ответы на свои уроки
|
||
if user.role == 'mentor':
|
||
queryset = queryset.filter(lesson__mentor=user)
|
||
else:
|
||
# Ученик видит только свои ответы
|
||
queryset = queryset.filter(student=user)
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'lesson_id', 'student_id', 'checked_by_id', 'answer_text',
|
||
'score', 'max_score', 'feedback', 'status', 'submitted_at', 'graded_at',
|
||
'created_at', 'updated_at'
|
||
)
|
||
|
||
return queryset
|
||
|
||
def get_serializer_class(self):
|
||
"""Выбор сериализатора."""
|
||
if self.action == 'create':
|
||
return LessonHomeworkSubmissionCreateSerializer
|
||
elif self.action == 'grade':
|
||
return LessonHomeworkSubmissionGradeSerializer
|
||
return LessonHomeworkSubmissionSerializer
|
||
|
||
def get_serializer_context(self):
|
||
"""Добавляем request в контекст."""
|
||
context = super().get_serializer_context()
|
||
context['request'] = self.request
|
||
return context
|
||
|
||
def create(self, request, *args, **kwargs):
|
||
"""Создание ответа на ДЗ (сдача ДЗ учеником)."""
|
||
# Проверяем, что пользователь - ученик
|
||
if request.user.role != 'client':
|
||
return Response(
|
||
{'error': 'Только ученики могут сдавать ДЗ'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Проверяем, что урок существует и у него есть ДЗ
|
||
lesson_id = request.data.get('lesson')
|
||
try:
|
||
lesson = Lesson.objects.get(id=lesson_id)
|
||
except Lesson.DoesNotExist:
|
||
return Response(
|
||
{'error': 'Урок не найден'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
|
||
# Проверяем, что ученик является участником урока
|
||
if lesson.client.user != request.user:
|
||
return Response(
|
||
{'error': 'Вы не являетесь участником этого урока'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Проверяем, что у урока есть домашнее задание
|
||
if not lesson.homework_text:
|
||
return Response(
|
||
{'error': 'У этого урока нет домашнего задания'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
# Проверяем, не сдано ли уже ДЗ
|
||
existing_submission = LessonHomeworkSubmission.objects.filter(
|
||
lesson=lesson,
|
||
student=request.user
|
||
).first()
|
||
|
||
if existing_submission:
|
||
# Если уже есть ответ, обновляем его
|
||
serializer = self.get_serializer(existing_submission, data=request.data, partial=True)
|
||
serializer.is_valid(raise_exception=True)
|
||
submission = serializer.save()
|
||
else:
|
||
# Создаем новый ответ
|
||
serializer = self.get_serializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
submission = serializer.save()
|
||
|
||
# Отправляем уведомление ментору о сданном ДЗ
|
||
from apps.notifications.services import NotificationService
|
||
from apps.homework.models import Homework
|
||
try:
|
||
homework = Homework.objects.get(lesson=lesson)
|
||
NotificationService.send_homework_notification(
|
||
homework,
|
||
'homework_submitted',
|
||
student=request.user
|
||
)
|
||
except Homework.DoesNotExist:
|
||
pass
|
||
|
||
response_serializer = LessonHomeworkSubmissionSerializer(
|
||
submission,
|
||
context={'request': request}
|
||
)
|
||
return Response(
|
||
response_serializer.data,
|
||
status=status.HTTP_201_CREATED if not existing_submission else status.HTTP_200_OK
|
||
)
|
||
|
||
@action(detail=True, methods=['post', 'get'])
|
||
def confirm_attendance(self, request, pk=None):
|
||
"""
|
||
Подтверждение присутствия студента на занятии.
|
||
|
||
POST /api/schedule/lessons/{id}/confirm-attendance/
|
||
Body: {"response": "yes"} или {"response": "no"}
|
||
|
||
GET /api/schedule/lessons/{id}/confirm-attendance/?response=yes
|
||
GET /api/schedule/lessons/{id}/confirm-attendance/?response=no
|
||
"""
|
||
lesson = self.get_object()
|
||
|
||
# Проверяем, что пользователь - студент этого занятия
|
||
if not lesson.client or lesson.client.user != request.user:
|
||
return Response({
|
||
'success': False,
|
||
'error': 'Только студент может подтвердить присутствие'
|
||
}, status=status.HTTP_403_FORBIDDEN)
|
||
|
||
# Получаем ответ из body или query params
|
||
if request.method == 'POST':
|
||
response_str = request.data.get('response', '').lower()
|
||
else:
|
||
response_str = request.query_params.get('response', '').lower()
|
||
|
||
if response_str not in ['yes', 'no', 'да', 'нет']:
|
||
return Response({
|
||
'success': False,
|
||
'error': 'Неверный ответ. Используйте "yes" или "no"'
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# Определяем булево значение
|
||
response_bool = response_str in ['yes', 'да']
|
||
|
||
# Сохраняем ответ
|
||
lesson.attendance_confirmed = response_bool
|
||
lesson.attendance_response_at = timezone.now()
|
||
lesson.save(update_fields=['attendance_confirmed', 'attendance_response_at'])
|
||
|
||
# Отправляем уведомление ментору
|
||
from apps.notifications.services import NotificationService
|
||
NotificationService.send_attendance_response_to_mentor(lesson, response_bool)
|
||
|
||
response_text = "будете присутствовать" if response_bool else "не сможете присутствовать"
|
||
|
||
return Response({
|
||
'success': True,
|
||
'message': f'Вы подтвердили, что {response_text} на занятии',
|
||
'data': LessonDetailSerializer(lesson).data
|
||
})
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def grade(self, request, pk=None):
|
||
"""
|
||
Выставить оценку за ДЗ (для ментора).
|
||
|
||
POST /api/schedule/lesson-homework-submissions/{id}/grade/
|
||
Body: {
|
||
"score": 85,
|
||
"feedback": "Отличная работа!"
|
||
}
|
||
"""
|
||
submission = self.get_object()
|
||
|
||
# Проверяем права (только ментор урока)
|
||
if submission.lesson.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.grade(
|
||
score=serializer.validated_data['score'],
|
||
feedback=serializer.validated_data.get('feedback', ''),
|
||
checked_by=request.user
|
||
)
|
||
|
||
# Отправляем уведомление о проверке ДЗ
|
||
from apps.notifications.services import NotificationService
|
||
from apps.homework.models import Homework
|
||
try:
|
||
homework = Homework.objects.get(lesson=submission.lesson)
|
||
NotificationService.send_homework_notification(
|
||
homework,
|
||
'homework_reviewed',
|
||
student=submission.student,
|
||
submission=submission
|
||
)
|
||
except Homework.DoesNotExist:
|
||
pass
|
||
|
||
response_serializer = LessonHomeworkSubmissionSerializer(
|
||
submission,
|
||
context={'request': request}
|
||
)
|
||
return Response(response_serializer.data)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def return_for_revision(self, request, pk=None):
|
||
"""
|
||
Вернуть ДЗ на доработку (для ментора).
|
||
|
||
POST /api/schedule/lesson-homework-submissions/{id}/return_for_revision/
|
||
Body: {
|
||
"feedback": "Необходимо доработать..."
|
||
}
|
||
"""
|
||
submission = self.get_object()
|
||
|
||
# Проверяем права (только ментор урока)
|
||
if submission.lesson.mentor != request.user:
|
||
return Response(
|
||
{'error': 'Только ментор может вернуть на доработку'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
feedback = request.data.get('feedback', '')
|
||
submission.return_for_revision(feedback)
|
||
|
||
response_serializer = LessonHomeworkSubmissionSerializer(
|
||
submission,
|
||
context={'request': request}
|
||
)
|
||
return Response(response_serializer.data)
|
||
|
||
|
||
class SubjectViewSet(viewsets.ReadOnlyModelViewSet):
|
||
"""
|
||
ViewSet для просмотра предметов.
|
||
Только чтение - предметы создаются администратором или автоматически.
|
||
"""
|
||
queryset = Subject.objects.filter(is_active=True)
|
||
serializer_class = SubjectSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
pagination_class = None # Отключаем пагинацию для предметов
|
||
|
||
def get_queryset(self):
|
||
"""Фильтрация по поисковому запросу."""
|
||
queryset = super().get_queryset()
|
||
search = self.request.query_params.get('search', None)
|
||
if search:
|
||
queryset = queryset.filter(name__icontains=search)
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only('id', 'name', 'is_active', 'created_at', 'updated_at')
|
||
|
||
return queryset.order_by('name')
|
||
|
||
|
||
class MentorSubjectViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
ViewSet для управления кастомными предметами ментора.
|
||
"""
|
||
serializer_class = MentorSubjectSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
pagination_class = None # Отключаем пагинацию для предметов ментора
|
||
|
||
def get_queryset(self):
|
||
"""Получить только предметы текущего ментора."""
|
||
if self.request.user.role != 'mentor':
|
||
return MentorSubject.objects.none()
|
||
|
||
queryset = MentorSubject.objects.filter(mentor=self.request.user).select_related('mentor')
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'mentor_id', 'name', 'usage_count', 'created_at', 'updated_at'
|
||
)
|
||
|
||
return queryset.order_by('name')
|
||
|
||
def get_serializer_class(self):
|
||
"""Использовать разные сериализаторы для создания и просмотра."""
|
||
if self.action == 'create':
|
||
return MentorSubjectCreateSerializer
|
||
return MentorSubjectSerializer
|
||
|
||
def perform_create(self, serializer):
|
||
"""Создать кастомный предмет для текущего ментора."""
|
||
serializer.save(mentor=self.request.user)
|