540 lines
21 KiB
Python
540 lines
21 KiB
Python
"""
|
||
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} МБ'
|
||
})
|