diff --git a/apps/article/serializers.py b/apps/article/serializers.py index bc4bfa9..e5b4e77 100644 --- a/apps/article/serializers.py +++ b/apps/article/serializers.py @@ -12,6 +12,8 @@ class ArticleCategoryListSerializer(serializers.ModelSerializer): fields = ['id', 'title', 'slug', 'acticle_count'] def get_acticle_count(self, obj): + if hasattr(obj, 'article_count_annotated'): + return obj.article_count_annotated return obj.articles.filter(status=True).count() class PinnedArticleCollectionSerializer(serializers.ModelSerializer): diff --git a/apps/article/views.py b/apps/article/views.py index 2f0d092..751b08a 100755 --- a/apps/article/views.py +++ b/apps/article/views.py @@ -1,4 +1,4 @@ -from django.db.models import Q +from django.db.models import Q, Count, Prefetch from rest_framework import generics, status from rest_framework.response import Response from rest_framework.decorators import action @@ -39,7 +39,9 @@ class ArticleCategoryListAPIView(generics.ListAPIView): return super().get(request, *args, **kwargs) def get_queryset(self): - return ArticleCategory.objects.filter(status=True).order_by('order') + return ArticleCategory.objects.filter(status=True).annotate( + article_count_annotated=Count('articles', filter=Q(articles__status=True), distinct=True) + ).order_by('order') class PinnedArticleCollectionListView(generics.ListAPIView): @@ -170,7 +172,14 @@ class ArticleListAPIView(generics.ListAPIView): return super().get(request, *args, **kwargs) def get_queryset(self): - queryset = Article.objects.filter(status=True) + queryset = Article.objects.filter(status=True).prefetch_related( + Prefetch( + 'categories', + queryset=ArticleCategory.objects.annotate( + article_count_annotated=Count('articles', filter=Q(articles__status=True), distinct=True) + ) + ) + ) # Search by title if search parameter is provided search_query = self.request.query_params.get('search', None) diff --git a/apps/bookmark/serializers/bookmark.py b/apps/bookmark/serializers/bookmark.py index 8a80666..2814418 100644 --- a/apps/bookmark/serializers/bookmark.py +++ b/apps/bookmark/serializers/bookmark.py @@ -64,11 +64,20 @@ class BookmarkStatusSerializer(serializers.Serializer): 'content_id': None } - is_bookmarked = Bookmark.is_bookmarked( - user=user, - service=service, - content_id=content_id - ) + # Cache active bookmarks on the user object to eliminate N+1 queries across list views + if not hasattr(user, '_cached_bookmarks'): + user._cached_bookmarks = {} + + if service not in user._cached_bookmarks: + user._cached_bookmarks[service] = set( + Bookmark.objects.filter( + user=user, + service=service, + status=True + ).values_list('content_id', flat=True) + ) + + is_bookmarked = content_id in user._cached_bookmarks[service] return { 'is_bookmarked': is_bookmarked, diff --git a/apps/hadis/models/reference.py b/apps/hadis/models/reference.py index c87c537..55d5ec7 100644 --- a/apps/hadis/models/reference.py +++ b/apps/hadis/models/reference.py @@ -194,9 +194,24 @@ class BookReference(LowercaseSlugMixin, models.Model): @property def authors(self): """ - Backward compatibility helper returning a queryset of authors. - Allows serializers calling obj.authors.all() to function correctly. + Backward compatibility helper returning a queryset/list of authors. + Allows serializers calling obj.authors.all() to function correctly without hitting DB if author is already cached. """ + if hasattr(self, '_state') and hasattr(self._state, 'fields_cache') and 'author' in self._state.fields_cache: + author_obj = self._state.fields_cache['author'] + class InMemAuthorList(list): + def all(self): + return self + def first(self): + return self[0] if self else None + def exists(self): + return bool(self) + def count(self): + return len(self) + def filter(self, *args, **kwargs): + return self + return InMemAuthorList([author_obj] if author_obj else []) + if self.author_id: return BookAuthor.objects.filter(id=self.author_id) return BookAuthor.objects.none() diff --git a/apps/hadis/serializers/reference.py b/apps/hadis/serializers/reference.py index 09eb42a..cf3a441 100644 --- a/apps/hadis/serializers/reference.py +++ b/apps/hadis/serializers/reference.py @@ -205,8 +205,10 @@ class BookReferenceSyncSerializer(serializers.ModelSerializer): def get_detail(self, obj): """Get basic book information""" request = self.context.get('request') - edition = obj.editions.first() - vol = obj.volumes.first() + editions = list(obj.editions.all()) + edition = editions[0] if editions else None + volumes = list(obj.volumes.all()) + vol = volumes[0] if volumes else None publisher_data = edition.publisher if edition else [] isbn_data = edition.isbn if edition else "" diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py index 9510fbb..cd6dfce 100644 --- a/apps/hadis/views/hadis.py +++ b/apps/hadis/views/hadis.py @@ -128,7 +128,7 @@ class HadisSyncView(ListAPIView): pagination_class = NoPagination def get_queryset(self): - return ( + qs = ( Hadis.objects .filter(status=True) .select_related('category', 'hadis_status') @@ -200,6 +200,19 @@ class HadisSyncView(ListAPIView): ) .order_by('id') ) + + # Incremental sync support to prevent transferring 25MB on subsequent syncs + updated_after = self.request.query_params.get('updated_after') or self.request.query_params.get('since') + if updated_after: + from django.utils.dateparse import parse_datetime + try: + dt = parse_datetime(updated_after) + if dt: + qs = qs.filter(updated_at__gte=dt) + except Exception: + pass + + return qs @hadis_sync_swagger def get(self, request, *args, **kwargs): @@ -396,8 +409,14 @@ class HadisMainListView(ListAPIView): return self.list(request, *args, **kwargs) def get_queryset(self): - # queryset = Hadis.objects.select_related('category', 'hadis_status') - queryset = Hadis.objects.select_related('category__sect', 'hadis_status') + queryset = Hadis.objects.select_related('category__sect', 'hadis_status').prefetch_related( + Prefetch( + 'references', + queryset=HadisReference.objects.select_related('book_reference__author').prefetch_related( + Prefetch('images', queryset=ReferenceImage.objects.order_by('priority')) + ) + ) + ) # Get search parameters search_query = self.request.query_params.get('search', None) diff --git a/apps/hadis/views/reference.py b/apps/hadis/views/reference.py index d840958..11b4e1e 100644 --- a/apps/hadis/views/reference.py +++ b/apps/hadis/views/reference.py @@ -112,7 +112,7 @@ class BookReferenceSyncView(ListAPIView): Prefetch ALL related data to avoid N+1 queries """ qs = BookReference.objects.select_related('author', 'type').prefetch_related( - 'attributes', 'images', 'hadis_references__hadis' + 'editions', 'volumes', 'attributes', 'images', 'hadis_references__hadis' ).distinct().order_by('id') # Category (Multi-select) diff --git a/apps/library/views.py b/apps/library/views.py index cb5f097..a94cc78 100644 --- a/apps/library/views.py +++ b/apps/library/views.py @@ -120,7 +120,7 @@ class BookListView(ListAPIView): return context def get_queryset(self): - queryset = Book.objects.filter(status=True).select_related('author') + queryset = Book.objects.filter(status=True).select_related('author', 'language') # Filter by collection if provided collection_id = self.request.query_params.get('collection_id') diff --git a/apps/podcast/models.py b/apps/podcast/models.py index 9157c0e..3a97991 100644 --- a/apps/podcast/models.py +++ b/apps/podcast/models.py @@ -204,6 +204,16 @@ class PodcastPlaylist(LowercaseSlugMixin, models.Model): """ if self.thumbnail: return self.thumbnail + + if hasattr(self, '_prefetched_objects_cache') and 'playlist_items' in self._prefetched_objects_cache: + items = sorted( + [item for item in self.playlist_items.all() if getattr(item, 'podcast', None) and item.podcast.status and item.podcast.thumbnail], + key=lambda x: getattr(x, 'priority', 0) + ) + if items and items[0].podcast and items[0].podcast.thumbnail: + return items[0].podcast.thumbnail + return None + first_item = self.playlist_items.filter( podcast__thumbnail__isnull=False, podcast__status=True diff --git a/apps/podcast/serializers.py b/apps/podcast/serializers.py index adc5cd0..72ebacf 100755 --- a/apps/podcast/serializers.py +++ b/apps/podcast/serializers.py @@ -213,6 +213,8 @@ class PodcastPlaylistListSerializer(serializers.ModelSerializer): def get_episodes_count(self, obj): """Return the number of episodes (podcasts) in this playlist""" + if hasattr(obj, 'episodes_count_annotated'): + return obj.episodes_count_annotated return obj.playlist_items.filter(podcast__status=True).count() diff --git a/apps/podcast/views.py b/apps/podcast/views.py index 7cb3911..d1d7cbb 100644 --- a/apps/podcast/views.py +++ b/apps/podcast/views.py @@ -1,4 +1,4 @@ -from django.db.models import Q, Prefetch +from django.db.models import Q, Prefetch, Count from rest_framework import generics, status from rest_framework.authentication import TokenAuthentication from rest_framework.decorators import action @@ -189,7 +189,13 @@ class PodcastListAPIView(generics.ListAPIView): queryset = PodcastPlaylist.objects.filter( status=True, playlist_items__podcast__status=True - ).distinct() + ).annotate( + episodes_count_annotated=Count( + 'playlist_items', + filter=Q(playlist_items__podcast__status=True), + distinct=True + ) + ).prefetch_related('playlist_items__podcast').distinct() # Search by title if search parameter is provided search_query = self.request.query_params.get('search', None) diff --git a/apps/video/models.py b/apps/video/models.py index 98c61b1..97b386b 100644 --- a/apps/video/models.py +++ b/apps/video/models.py @@ -304,6 +304,16 @@ class VideoPlaylist(LowercaseSlugMixin, models.Model): """ if self.thumbnail: return self.thumbnail + + if hasattr(self, '_prefetched_objects_cache') and 'playlist_items' in self._prefetched_objects_cache: + items = sorted( + [item for item in self.playlist_items.all() if getattr(item, 'video', None) and item.video.status and item.video.thumbnail], + key=lambda x: getattr(x, 'priority', 0) + ) + if items and items[0].video and items[0].video.thumbnail: + return items[0].video.thumbnail + return None + first_item = self.playlist_items.filter( video__thumbnail__isnull=False, video__status=True diff --git a/apps/video/views.py b/apps/video/views.py index c6e03cb..28b6f29 100644 --- a/apps/video/views.py +++ b/apps/video/views.py @@ -207,7 +207,7 @@ class VideoPlaylistListAPIView(generics.ListAPIView): queryset = VideoPlaylist.objects.filter( status=True, playlist_items__video__status=True - ).distinct().order_by('order', '-created_at') + ).prefetch_related('playlist_items__video').distinct().order_by('order', '-created_at') # Search by title if search parameter is provided search_query = self.request.query_params.get('search', None) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index eca0090..35e22a5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,7 +7,7 @@ services: build: context: . dockerfile: Dockerfile.prod - command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers=2 --threads=2 --max-requests=1000 --max-requests-jitter=100 --timeout 180 + command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers=4 --threads=4 --worker-class=gthread --max-requests=1000 --max-requests-jitter=100 --timeout 180 volumes: # - static_volume:/usr/src/app/static - media_volume:/usr/src/app/media