uchill/backend/apps/materials/views.py

540 lines
21 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.

"""
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} МБ'
})