uchill/backend/apps/schedule/views.py

1533 lines
69 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.

"""
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 user.role == 'client':
try:
queryset = queryset.filter(client=user.client_profile)
except:
queryset = Lesson.objects.none()
# Родители видят занятия своих детей
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
has_homework = request_has_text or request_has_files
homework_id = None
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',
)
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)
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 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
)
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)