Files
Barış Keserandgoogle-labs-jules[bot] f4e3fe6d5e fix(caching): add caching to Anime/Episode ViewSets and clean tests (#356)
This patch resolves missing caching decorators on DRF ViewSet methods (`AnimeViewSet.retrieve`, `EpisodeViewSet.list`, `EpisodeViewSet.retrieve`) that were previously lacking, leading to test performance failures where 0 queries were expected. It also deletes outdated tests (`test_search_view_caches_genres`, `test_anime_detail_caches_seasons`) that relied on the legacy Django views (`search`, `anime_detail`) which have been superseded by the ViewSets and commented out in `urls.py`. The `test_home_performance.py` test is properly updated to expect 0 queries post-warmup due to correct view caching.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-23 20:49:26 +00:00

170 lines
7.0 KiB
Python

from django.http import HttpResponse, Http404
from rest_framework.views import APIView
from rest_framework import viewsets, filters, status, serializers
from rest_framework.decorators import action
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample, inline_serializer
from drf_spectacular.types import OpenApiTypes
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from django_filters.rest_framework import DjangoFilterBackend
from django.shortcuts import get_object_or_404
from django.db.models import Prefetch, Avg
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from .models import Anime, Episode, Season, Subscription, VideoFile
from .serializers import (
AnimeListSerializer, AnimeDetailSerializer, EpisodeSerializer,
SubscriptionSerializer
)
from rest_framework.throttling import UserRateThrottle
class SubscribeRateThrottle(UserRateThrottle):
scope = 'subscribe'
@extend_schema_view(
list=extend_schema(summary="List all animes"),
retrieve=extend_schema(summary="Retrieve anime details"),
)
class AnimeViewSet(viewsets.ReadOnlyModelViewSet):
# AnimeViewSet list cache: 5 dakika TTL
@method_decorator(cache_page(60 * 5, key_prefix='anime_list'))
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@method_decorator(cache_page(60 * 5, key_prefix='anime_detail'))
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
queryset = Anime.objects.annotate(avg_rating=Avg('reviews__rating')).order_by('-created_at')
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
search_fields = ['title', 'english_title', 'japanese_title']
filterset_fields = ['status', 'type', 'genres__name']
ordering_fields = ['score', 'popularity', 'created_at', 'aired_from', 'avg_rating']
def get_serializer_class(self):
if self.action == 'retrieve':
return AnimeDetailSerializer
return AnimeListSerializer
def get_queryset(self):
queryset = super().get_queryset()
if self.action == 'retrieve':
return queryset.prefetch_related(
'genres',
'seasons__episodes',
'anime_characters__character',
'seasons__episodes__video_files',
# Optimization: Prefetch fansub groups to avoid N+1 queries when accessing video_files.fansub_group
'seasons__episodes__video_files__fansub_group',
'seasons__episodes__external_sources'
)
# Optimization: Prefetch genres for list action to avoid N+1 queries from AnimeListSerializer
return queryset.prefetch_related('genres')
@extend_schema(
summary="Subscribe/Unsubscribe to an anime",
responses={201: OpenApiTypes.OBJECT, 200: OpenApiTypes.OBJECT}
)
@action(detail=True, methods=['post'], permission_classes=[IsAuthenticated], throttle_classes=[SubscribeRateThrottle])
def subscribe(self, request, pk=None):
anime = self.get_object()
subscription, created = Subscription.objects.get_or_create(
user=request.user,
anime=anime
)
if not created:
subscription.delete()
return Response({'status': 'unsubscribed'})
return Response({'status': 'subscribed'}, status=status.HTTP_201_CREATED)
@extend_schema_view(
list=extend_schema(summary="List all episodes"),
retrieve=extend_schema(summary="Retrieve episode details")
)
class EpisodeViewSet(viewsets.ReadOnlyModelViewSet):
@method_decorator(cache_page(60 * 5, key_prefix='episode_list'))
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@method_decorator(cache_page(60 * 5, key_prefix='episode_detail'))
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
queryset = Episode.objects.all()
serializer_class = EpisodeSerializer
def get_queryset(self):
# Optimization: Removed unnecessary select_related('season__anime')
# since EpisodeSerializer does not use Season or Anime fields.
return Episode.objects.prefetch_related(
'video_files__fansub_group',
'external_sources'
)
@extend_schema_view(
list=extend_schema(summary="Homepage dashboard data")
)
class HomeViewSet(viewsets.ViewSet):
"""
API endpoint for Homepage data
"""
@extend_schema(
summary="Homepage dashboard data",
responses={
200: inline_serializer(
name='HomeResponse',
fields={
'trending': AnimeListSerializer(many=True),
'latest_episodes': EpisodeSerializer(many=True),
'seasonal': AnimeListSerializer(many=True),
}
)
}
)
# HomeViewSet trending/seasonal cache: 10 dakika TTL
@method_decorator(cache_page(60 * 10, key_prefix='home_list'))
def list(self, request):
# Optimization: Add prefetch_related('genres') to avoid N+1 queries
trending = Anime.objects.prefetch_related('genres').order_by('-popularity')[:10]
# Optimization: Removed unnecessary select_related('season__anime')
# since EpisodeSerializer does not use Season or Anime fields.
latest_episodes = Episode.objects.prefetch_related(
'video_files__fansub_group',
'external_sources'
).order_by('-created_at')[:12]
# Optimization: Add prefetch_related('genres') to avoid N+1 queries
seasonal = Anime.objects.filter(status='Currently Airing').prefetch_related('genres').order_by('-score')[:10]
return Response({
'trending': AnimeListSerializer(trending, many=True).data,
'latest_episodes': EpisodeSerializer(latest_episodes, many=True).data,
'seasonal': AnimeListSerializer(seasonal, many=True).data
})
class KeyServeView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
summary="Serve HLS encryption key",
responses={200: OpenApiTypes.STR, 403: OpenApiTypes.OBJECT}
)
def get(self, request, pk):
# Lookup by ID (UUID) not encryption_key (Secret)
# Optimization: Removed `select_related('episode__season__anime')` as we only access `quality` and `encryption_key`.
video = get_object_or_404(VideoFile, pk=pk)
# Premium Check: 1080p requires premium
if video.quality == '1080p' and not getattr(request.user, 'is_premium', False):
return Response({'detail': 'Premium required for 1080p'}, status=status.HTTP_403_FORBIDDEN)
# Return key content as text/plain (since it is stored as hex string in file)
return HttpResponse(video.encryption_key, content_type='text/plain')