""" API views для учебных материалов. """ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from django.db import models from django.http import FileResponse, Http404 from .models import Material, MaterialFolder, MaterialTag, MaterialAccess, StorageQuota from .serializers import ( MaterialSerializer, MaterialListSerializer, MaterialCreateSerializer, MaterialFolderSerializer, MaterialTagSerializer, StorageQuotaSerializer, MaterialAccessSerializer ) from .permissions import IsMaterialOwnerOrShared, IsMaterialOwner from apps.subscriptions.permissions import RequiresActiveSubscription from config.throttling import UploadRateThrottle # Кастомная пагинация для материалов с поддержкой page_size из запроса from rest_framework.pagination import PageNumberPagination class MaterialPagination(PageNumberPagination): page_size = 20 # Размер страницы по умолчанию page_size_query_param = 'page_size' # Параметр для изменения размера страницы max_page_size = 100 # Максимальный размер страницы class MaterialViewSet(viewsets.ModelViewSet): """ ViewSet для управления материалами. list: Список материалов create: Создать материал retrieve: Получить материал update: Обновить материал destroy: Удалить материал download: Скачать файл share: Предоставить доступ """ permission_classes = [IsAuthenticated, RequiresActiveSubscription] pagination_class = MaterialPagination def get_throttles(self): """Применяем throttling только для создания (загрузки файлов).""" if self.action == 'create': return [UploadRateThrottle()] return super().get_throttles() def get_queryset(self): """Получение материалов.""" user = self.request.user # Материалы пользователя и доступные ему. # Студент (client) видит только: свои, явно расшаренные (shared_with) и публичные. # Не показываем все материалы ментора с access_type='clients' — только shared_with. queryset = Material.objects.filter( models.Q(owner=user) | models.Q(shared_with__id=user.id) | models.Q(access_type='public') ).filter( is_deleted=False ) queryset = queryset.distinct().select_related( 'owner', 'folder' ).prefetch_related('tags', 'shared_with') # Фильтр по папке folder_id = self.request.query_params.get('folder') if folder_id: queryset = queryset.filter(folder_id=folder_id) # Фильтр по типу material_type = self.request.query_params.get('type') if material_type: queryset = queryset.filter(material_type=material_type) # Фильтр по тегам tags = self.request.query_params.getlist('tags') if tags: queryset = queryset.filter(tags__id__in=tags).distinct() # Поиск search = self.request.query_params.get('search') if search: queryset = queryset.filter( models.Q(title__icontains=search) | models.Q(description__icontains=search) | models.Q(file_name__icontains=search) ) # Оптимизация: для списка используем only() для ограничения полей if self.action == 'list': queryset = queryset.only( 'id', 'title', 'description', 'file', 'file_name', 'file_size', 'file_type', 'url', 'material_type', 'owner_id', 'folder_id', 'access_type', 'is_featured', 'views_count', 'downloads_count', 'created_at', 'updated_at' ) return queryset def get_serializer_class(self): """Выбор сериализатора.""" if self.action == 'list': return MaterialListSerializer elif self.action == 'create': return MaterialCreateSerializer return MaterialSerializer def create(self, request, *args, **kwargs): """Создание материала.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) # Сериализатор сам устанавливает owner в методе create material = serializer.save() response_serializer = MaterialSerializer(material) return Response( response_serializer.data, status=status.HTTP_201_CREATED ) def retrieve(self, request, *args, **kwargs): """Получить материал.""" material = self.get_object() # Проверяем доступ if not material.has_access(request.user): return Response( {'error': 'У вас нет доступа к этому материалу'}, status=status.HTTP_403_FORBIDDEN ) # Увеличиваем счетчик просмотров material.increment_views() # Логируем доступ MaterialAccess.objects.create( material=material, user=request.user, action='view', ip_address=request.META.get('REMOTE_ADDR'), user_agent=request.META.get('HTTP_USER_AGENT', '')[:500] ) serializer = self.get_serializer(material) return Response(serializer.data) def destroy(self, request, *args, **kwargs): """Удаление материала (мягкое).""" material = self.get_object() # Проверяем права if material.owner != request.user: return Response( {'error': 'Только владелец может удалить материал'}, status=status.HTTP_403_FORBIDDEN ) # Сохраняем размер файла file_size = material.file_size # Мягкое удаление material.soft_delete() # Обновляем квоту if material.file: from .services import StorageService StorageService.remove_file_usage(request.user, file_size) return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=['get']) def download(self, request, pk=None): """ Скачать файл. GET /api/materials/materials/{id}/download/ """ material = self.get_object() # Проверяем доступ if not material.has_access(request.user): return Response( {'error': 'У вас нет доступа к этому материалу'}, status=status.HTTP_403_FORBIDDEN ) # Проверяем разрешено ли скачивание if not material.allow_download: return Response( {'error': 'Скачивание этого материала запрещено'}, status=status.HTTP_403_FORBIDDEN ) # Проверяем наличие файла if not material.file: return Response( {'error': 'У материала нет файла'}, status=status.HTTP_404_NOT_FOUND ) # Увеличиваем счетчик скачиваний material.increment_downloads() # Логируем доступ MaterialAccess.objects.create( material=material, user=request.user, action='download', ip_address=request.META.get('REMOTE_ADDR'), user_agent=request.META.get('HTTP_USER_AGENT', '')[:500] ) # Отдаем файл response = FileResponse(material.file.open('rb')) response['Content-Type'] = material.file_type or 'application/octet-stream' response['Content-Disposition'] = f'attachment; filename="{material.file_name}"' return response @action(detail=True, methods=['post']) def share(self, request, pk=None): """ Предоставить доступ к материалу. POST /api/materials/materials/{id}/share/ Body: { "user_ids": [1, 2, 3] } """ material = self.get_object() # Проверяем права if material.owner != request.user: return Response( {'error': 'Только владелец может предоставить доступ'}, status=status.HTTP_403_FORBIDDEN ) user_ids = request.data.get('user_ids', []) # Обновляем полный список пользователей, которым доступен материал. # Если список пустой — доступ будет только у владельца (shared_with очищается). from apps.users.models import User import logging logger = logging.getLogger(__name__) logger.info(f"[MaterialViewSet.share] Material ID: {material.id}, User IDs: {user_ids}, Owner: {material.owner.id}") # Конвертируем строки в числа, если нужно if user_ids and isinstance(user_ids[0], str): user_ids = [int(uid) for uid in user_ids] users = User.objects.filter(id__in=user_ids) logger.info(f"[MaterialViewSet.share] Found users: {[u.id for u in users]}") material.shared_with.set(users) material.save() # Убеждаемся, что изменения сохранены # Проверяем, что пользователи действительно добавлены shared_user_ids = list(material.shared_with.values_list('id', flat=True)) logger.info(f"[MaterialViewSet.share] Material shared_with after save: {shared_user_ids}") # Оптимизация: создаем одну запись о шаринге (в оригинале создавалось N одинаковых записей) # Сохраняем логику создания записи, но используем одну запись вместо N MaterialAccess.objects.create( material=material, user=request.user, action='share', ip_address=request.META.get('REMOTE_ADDR') ) serializer = MaterialSerializer(material) return Response(serializer.data) @action(detail=False, methods=['get']) def my_materials(self, request): """ Мои материалы. GET /api/materials/materials/my_materials/ """ materials = Material.objects.filter( owner=request.user, is_deleted=False ).select_related('owner', 'folder').prefetch_related('tags').only( 'id', 'title', 'file', 'file_name', 'file_size', 'file_type', 'url', 'material_type', 'owner_id', 'folder_id', 'access_type', 'is_featured', 'views_count', 'downloads_count', 'created_at', 'updated_at' ) serializer = MaterialListSerializer(materials, many=True) return Response(serializer.data) @action(detail=False, methods=['get']) def shared_with_me(self, request): """ Материалы, к которым предоставлен доступ. GET /api/materials/materials/shared_with_me/ """ materials = Material.objects.filter( shared_with=request.user, is_deleted=False ).select_related('owner', 'folder').prefetch_related('tags').only( 'id', 'title', 'file', 'file_name', 'file_size', 'file_type', 'url', 'material_type', 'owner_id', 'folder_id', 'access_type', 'is_featured', 'views_count', 'downloads_count', 'created_at', 'updated_at' ) serializer = MaterialListSerializer(materials, many=True) return Response(serializer.data) @action(detail=False, methods=['get']) def featured(self, request): """ Избранные материалы. GET /api/materials/materials/featured/ """ materials = Material.objects.filter( owner=request.user, is_featured=True, is_deleted=False ).select_related('owner', 'folder').prefetch_related('tags').only( 'id', 'title', 'file', 'file_name', 'file_size', 'file_type', 'url', 'material_type', 'owner_id', 'folder_id', 'access_type', 'is_featured', 'views_count', 'downloads_count', 'created_at', 'updated_at' ) serializer = MaterialListSerializer(materials, many=True) return Response(serializer.data) class MaterialFolderViewSet(viewsets.ModelViewSet): """ ViewSet для управления папками материалов. """ permission_classes = [IsAuthenticated] serializer_class = MaterialFolderSerializer def get_queryset(self): """Получение папок.""" user = self.request.user queryset = MaterialFolder.objects.filter( models.Q(owner=user) | models.Q(shared_with=user) ).distinct().select_related('owner', 'parent') # Оптимизация: для списка используем only() для ограничения полей if self.action == 'list': queryset = queryset.only( 'id', 'name', 'description', 'owner_id', 'parent_id', 'color', 'icon', 'created_at', 'updated_at' ) return queryset def perform_create(self, serializer): """Создание папки.""" serializer.save(owner=self.request.user) class MaterialTagViewSet(viewsets.ModelViewSet): """ ViewSet для управления тегами материалов. """ permission_classes = [IsAuthenticated] serializer_class = MaterialTagSerializer def get_queryset(self): """Получение тегов.""" queryset = MaterialTag.objects.all() # Оптимизация: для списка используем only() для ограничения полей if self.action == 'list': queryset = queryset.only( 'id', 'name', 'slug', 'color', 'created_at', 'updated_at' ) return queryset class StorageQuotaViewSet(viewsets.ReadOnlyModelViewSet): """ ViewSet для просмотра квот хранилища. """ permission_classes = [IsAuthenticated] serializer_class = StorageQuotaSerializer def get_queryset(self): """Получение квот.""" queryset = StorageQuota.objects.filter(user=self.request.user).select_related('user') # Оптимизация: для списка используем only() для ограничения полей if self.action == 'list': queryset = queryset.only( 'id', 'user_id', 'total_mb', 'used_mb', 'available_mb', 'subscription_limit_mb', 'created_at', 'updated_at' ) return queryset @action(detail=False, methods=['get']) def my_quota(self, request): """ Моя квота. GET /api/materials/quotas/my_quota/ """ from .services import StorageService # Синхронизируем квоту с подпиской StorageService.sync_quota_with_subscription(request.user) quota, created = StorageQuota.objects.get_or_create(user=request.user) serializer = StorageQuotaSerializer(quota) return Response(serializer.data) @action(detail=False, methods=['get']) def stats(self, request): """ Статистика использования хранилища. GET /api/materials/quotas/stats/ """ from .services import StorageService stats = StorageService.get_storage_stats(request.user) return Response(stats) @action(detail=False, methods=['post']) def check_limit(self, request): """ Проверить лимит перед загрузкой файла. POST /api/materials/quotas/check_limit/ Body: {"file_size": 1024000} # размер в байтах """ from .services import StorageService file_size = request.data.get('file_size', 0) if not file_size or file_size <= 0: return Response( {'error': 'Необходимо указать размер файла (file_size в байтах)'}, status=status.HTTP_400_BAD_REQUEST ) can_upload, error_message, warning_message = StorageService.check_storage_limit( request.user, file_size ) if not can_upload: return Response({ 'can_upload': False, 'message': error_message, 'is_warning': False, 'is_critical': True }, status=status.HTTP_400_BAD_REQUEST) # Определяем, является ли предупреждение критическим (>= 90%) quota, _ = StorageQuota.objects.get_or_create(user=request.user) used_percentage = quota.get_used_percentage() is_critical = used_percentage >= 90 is_warning = used_percentage >= 80 and not is_critical return Response({ 'can_upload': True, 'message': warning_message, 'is_warning': is_warning, 'is_critical': is_critical }) @action(detail=False, methods=['post']) def recalculate(self, request): """ Пересчитать использование. POST /api/materials/quotas/recalculate/ """ from .services import StorageService # Синхронизируем квоту с подпиской перед пересчетом StorageService.sync_quota_with_subscription(request.user) from .services import StorageService # Синхронизируем квоту с подпиской перед пересчетом StorageService.sync_quota_with_subscription(request.user) quota, created = StorageQuota.objects.get_or_create(user=request.user) quota.recalculate() serializer = StorageQuotaSerializer(quota) return Response(serializer.data) @action(detail=False, methods=['post']) def cleanup_old_files(self, request): """ Очистить старые неиспользуемые файлы. POST /api/materials/quotas/cleanup_old_files/ Body: {"days_old": 90} # опционально, по умолчанию 90 дней """ from .services import StorageService days_old = request.data.get('days_old', 90) if not isinstance(days_old, int) or days_old < 1: return Response( {'error': 'days_old должен быть положительным числом'}, status=status.HTTP_400_BAD_REQUEST ) result = StorageService.cleanup_old_unused_files(request.user, days_old=days_old) return Response({ 'success': True, 'deleted_count': result['deleted_count'], 'freed_space_mb': round(result['freed_space_mb'], 2), 'message': f'Удалено {result["deleted_count"]} материалов, освобождено {result["freed_space_mb"]:.2f} МБ' })