""" 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)