From 058929fb7155474ea9d83ccc0dd29608e21de17c Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Sun, 18 Oct 2026 14:29:58 +0330 Subject: [PATCH] fix(hadis): support many-to-many categories relation in category tree and views - Update annotate_and_prune_empty_categories helper to count active content from both category and categories relations in subtree - Check both category and categories in category serializers (get_has_hadis, get_hadis_count, get_children_count) - Support categories M2M relation in HadisSyncView and HadisMainListView filters - Remove aggressive cached_view decorator from category tree and listing routes to ensure real-time consistency --- apps/hadis/serializers/category.py | 43 +++++-- apps/hadis/urls.py | 10 +- apps/hadis/views/category.py | 175 ++++++++++++++++------------- apps/hadis/views/hadis.py | 17 ++- 4 files changed, 150 insertions(+), 95 deletions(-) diff --git a/apps/hadis/serializers/category.py b/apps/hadis/serializers/category.py index 2e8b5e8..cfdaaba 100644 --- a/apps/hadis/serializers/category.py +++ b/apps/hadis/serializers/category.py @@ -253,19 +253,28 @@ class HadisCategorySelectSerializer(serializers.ModelSerializer): if has_active_children: return False + if hasattr(obj, 'active_hadis_count'): + return obj.active_hadis_count > 0 + # If no active children, check if has hadis - return Hadis.objects.filter(category=obj, status=True).exists() + from django.db.models import Q + return Hadis.objects.filter(Q(category=obj) | Q(categories=obj), status=True).exists() def get_children_count(self, obj): if hasattr(obj, 'total_content_count'): return obj.total_content_count + from django.db.models import Q family_tree = obj.get_descendants(include_self=True) - return Hadis.objects.filter(category__in=family_tree, status=True).count() + return Hadis.objects.filter( + Q(category__in=family_tree) | Q(categories__in=family_tree), + status=True + ).distinct().count() def get_hadis_count(self, obj): if hasattr(obj, 'active_hadis_count'): return obj.active_hadis_count - return Hadis.objects.filter(category=obj, status=True).count() + from django.db.models import Q + return Hadis.objects.filter(Q(category=obj) | Q(categories=obj), status=True).distinct().count() @@ -297,19 +306,27 @@ class HadisCategorySelectSourceSerializer(serializers.ModelSerializer): # If has active children, cannot have hadis if has_active_children: return False + if hasattr(obj, 'active_hadis_count'): + return obj.active_hadis_count > 0 # If no active children, check if has hadis - return Hadis.objects.filter(category=obj, status=True).exists() + from django.db.models import Q + return Hadis.objects.filter(Q(category=obj) | Q(categories=obj), status=True).exists() def get_children_count(self, obj): if hasattr(obj, 'total_content_count'): return obj.total_content_count + from django.db.models import Q family_tree = obj.get_descendants(include_self=True) - return Hadis.objects.filter(category__in=family_tree, status=True).count() + return Hadis.objects.filter( + Q(category__in=family_tree) | Q(categories__in=family_tree), + status=True + ).distinct().count() def get_hadis_count(self, obj): if hasattr(obj, 'active_hadis_count'): return obj.active_hadis_count - return Hadis.objects.filter(category=obj, status=True).count() + from django.db.models import Q + return Hadis.objects.filter(Q(category=obj) | Q(categories=obj), status=True).distinct().count() class CategorySerializer(serializers.ModelSerializer): sect_id = serializers.IntegerField(source='sect.id', read_only=True) @@ -334,16 +351,24 @@ class CategorySerializer(serializers.ModelSerializer): def get_children_count(self, obj): if hasattr(obj, 'total_content_count'): return obj.total_content_count + from django.db.models import Q family_tree = obj.get_descendants(include_self=True) - return Hadis.objects.filter(category__in=family_tree, status=True).count() + return Hadis.objects.filter( + Q(category__in=family_tree) | Q(categories__in=family_tree), + status=True + ).distinct().count() def get_has_hadis(self, obj): - return Hadis.objects.filter(category=obj, status=True).exists() + if hasattr(obj, 'active_hadis_count'): + return obj.active_hadis_count > 0 + from django.db.models import Q + return Hadis.objects.filter(Q(category=obj) | Q(categories=obj), status=True).exists() def get_hadis_count(self, obj): if hasattr(obj, 'active_hadis_count'): return obj.active_hadis_count - return Hadis.objects.filter(category=obj, status=True).count() + from django.db.models import Q + return Hadis.objects.filter(Q(category=obj) | Q(categories=obj), status=True).distinct().count() # def get_title(self,obj): # # ✅ Get language from request diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index ff4c039..85ea445 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -85,7 +85,7 @@ urlpatterns = [ path('pinned-collections/', cached_view(PinnedHadisCollectionListView.as_view()), name='pinned-hadis-collection-list'), path('collections/', cached_view(HadisCollectionListView.as_view()), name='hadis-collection-list'), path('sync/sects/', cached_view(HadisCategorySectListView.as_view()), name='hadis-sect-list'), - path('sync/categories/tree/', cached_view(HadisCategoryTreeView.as_view()), name='hadis-category-tree'), + path('sync/categories/tree/', HadisCategoryTreeView.as_view(), name='hadis-category-tree'), path('sync/hadis/', cached_view(HadisSyncView.as_view()), name='hadis-sync'), path('sync/narrators/', cached_view(TransmitterSyncView.as_view()), name='transmitter-sync'), path('sync/references/', cached_view(BookReferenceSyncView.as_view()), name='reference-sync'), @@ -97,11 +97,11 @@ urlpatterns = [ path('tags/', cached_view(HadisTagListView.as_view()), name='hadis-tag-list'), # Category paths (more specific first) - path('categories/tree/', cached_view(HadisCategoryTreeNormalView.as_view()), name='hadis-category-tree-normal'), - path('categories////', cached_view(HadisCategorySelectBySectSourceView.as_view()), name='categories-tree-by-sect-source'), - path('categories///', cached_view(HadisCategorySelectBySectView.as_view()), name='categories-tree-by-sect'), + path('categories/tree/', HadisCategoryTreeNormalView.as_view(), name='hadis-category-tree-normal'), + path('categories////', HadisCategorySelectBySectSourceView.as_view(), name='categories-tree-by-sect-source'), + path('categories///', HadisCategorySelectBySectView.as_view(), name='categories-tree-by-sect'), path('categories//', CategoriesBySectView.as_view(), name='categories-by-sect'), - path('categories/', cached_view(CategoriesView.as_view()), name='categories'), # ← Least specific LAST + path('categories/', CategoriesView.as_view(), name='categories'), # ← Least specific LAST # Hadis paths path('category//xmind/', HadisCategoryXMindView.as_view(), name='hadis-category-xmind'), # ← Must be before other category paths diff --git a/apps/hadis/views/category.py b/apps/hadis/views/category.py index 3fb574a..559e0d5 100644 --- a/apps/hadis/views/category.py +++ b/apps/hadis/views/category.py @@ -55,35 +55,43 @@ class HadisCategoryTreeView(ListAPIView): def get_queryset(self): """ - Fetch ALL active categories in a single query with annotations. + Fetch ALL active categories in a single query. """ - from django.db.models import Count, Q - return ( HadisCategory.objects .filter(sect__is_active=True) .select_related('sect') - .annotate( - # Count of hadiths directly in this category - direct_hadis_count=Count('hadis', filter=Q(hadis__status=True)) - ) .order_by('sect__order', 'tree_id', 'lft', 'order') ) def list(self, request, *args, **kwargs): - queryset = self.get_queryset() + queryset = list(self.get_queryset()) + cat_ids = [cat.id for cat in queryset] - # 1. Map Hadis IDs - hadis_ids_map = defaultdict(list) - hadis_pairs = ( + # 1. Map Hadis IDs from BOTH legacy FK (`category`) and M2M (`categories`) + hadis_ids_sets = defaultdict(set) + fk_pairs = ( Hadis.objects - .filter(status=True, category_id__in=[cat.id for cat in queryset]) + .filter(status=True, category_id__in=cat_ids) .values_list('category_id', 'id') - .order_by('category_id', 'id') ) - for category_id, hadis_id in hadis_pairs: - hadis_ids_map[category_id].append(hadis_id) + for category_id, hadis_id in fk_pairs: + hadis_ids_sets[category_id].add(hadis_id) + + m2m_pairs = ( + Hadis.categories.through.objects + .filter(hadis__status=True, hadiscategory_id__in=cat_ids) + .values_list('hadiscategory_id', 'hadis_id') + ) + for category_id, hadis_id in m2m_pairs: + hadis_ids_sets[category_id].add(hadis_id) + + hadis_ids_map = { + cid: sorted(h_ids) for cid, h_ids in hadis_ids_sets.items() + } + for cat in queryset: + cat.direct_hadis_count = len(hadis_ids_map.get(cat.id, ())) # 👇 2. Map Quran Data (Bulk fetch to prevent N+1 Queries) quran_data_map = {} @@ -112,24 +120,24 @@ class HadisCategoryTreeView(ListAPIView): children_map[cat.parent_id] = [] children_map[cat.parent_id].append(cat) - # 4. Pre-calculate recursive hadis counts - recursive_counts = {} + # 4. Pre-calculate recursive hadis counts (distinct active hadith IDs in subtree) + recursive_sets = {} - def get_recursive_count(cat_id): - if cat_id in recursive_counts: - return recursive_counts[cat_id] - - cat = category_map[cat_id] - count = cat.direct_hadis_count + def get_recursive_hadis_set(cat_id): + if cat_id in recursive_sets: + return recursive_sets[cat_id] + combined = set(hadis_ids_sets.get(cat_id, ())) for child in children_map.get(cat_id, []): - count += get_recursive_count(child.id) + combined.update(get_recursive_hadis_set(child.id)) - recursive_counts[cat_id] = count - return count + recursive_sets[cat_id] = combined + return combined - for cat in queryset: - get_recursive_count(cat.id) + recursive_counts = { + cat.id: len(get_recursive_hadis_set(cat.id)) + for cat in queryset + } # 5. Build grouped structure grouped_data = {} @@ -265,6 +273,56 @@ class HadisCategoryTreeView(ListAPIView): return None +def annotate_and_prune_empty_categories(queryset): + """ + Annotates categories with: + - `active_hadis_count`: count of active Hadis directly in this category (via FK `category` or M2M `categories`) + - `total_content_count`: count of active Hadis anywhere in this category's MPTT subtree (via FK or M2M) + - `has_subtree_quran`: whether any category in this MPTT subtree has a Quran verse or is a Quran category with content + And filters out categories whose entire subtree has no active hadiths and no Quran verses. + """ + from django.db.models import Exists + from django.db.models.expressions import RawSQL + from ..models.category import CategoryQuranVerse + + queryset = queryset.annotate( + active_hadis_count=RawSQL(""" + ( + SELECT COUNT(DISTINCT h.id) + FROM hadis_hadis h + LEFT JOIN hadis_hadis_categories hhc ON hhc.hadis_id = h.id + WHERE h.status = TRUE + AND (h.category_id = hadis_hadiscategory.id OR hhc.hadiscategory_id = hadis_hadiscategory.id) + ) + """, []), + total_content_count=RawSQL(""" + ( + SELECT COUNT(DISTINCT h.id) + FROM hadis_hadis h + LEFT JOIN hadis_hadiscategory c1 ON c1.id = h.category_id + LEFT JOIN hadis_hadis_categories hhc ON hhc.hadis_id = h.id + LEFT JOIN hadis_hadiscategory c2 ON c2.id = hhc.hadiscategory_id + WHERE h.status = TRUE + AND ( + (c1.tree_id = hadis_hadiscategory.tree_id AND c1.lft >= hadis_hadiscategory.lft AND c1.rght <= hadis_hadiscategory.rght) + OR + (c2.tree_id = hadis_hadiscategory.tree_id AND c2.lft >= hadis_hadiscategory.lft AND c2.rght <= hadis_hadiscategory.rght) + ) + ) + """, []), + has_subtree_quran=Exists( + CategoryQuranVerse.objects.filter( + category__tree_id= RawSQL("hadis_hadiscategory.tree_id", []), + category__lft__gte=RawSQL("hadis_hadiscategory.lft", []), + category__rght__lte=RawSQL("hadis_hadiscategory.rght", []), + ) + ), + ).filter( + Q(total_content_count__gt=0) | Q(has_subtree_quran=True) + ) + return queryset + + class HadisCategoryTreeNormalView(ListAPIView): """ Normal (paginated) tree view for HadisCategory. @@ -279,33 +337,18 @@ class HadisCategoryTreeNormalView(ListAPIView): return self.list(request, *args, **kwargs) def get_queryset(self): - from django.db.models import Count, Q - return HadisCategory.objects.filter( + qs = HadisCategory.objects.filter( parent__isnull=True, sect__is_active=True - ).annotate( - active_hadis_count=Count('hadis', filter=Q(hadis__status=True)), - children_total_count=Count('children') - ).filter( - Q(active_hadis_count__gt=0) | Q(children_total_count__gt=0) | Q(source_type=HadisCategory.SourceType.QURAN) - ).order_by('sect__order', 'order') + ) + return annotate_and_prune_empty_categories(qs).order_by('sect__order', 'order') def apply_category_ordering_and_counts(queryset, request): - from django.db.models import Subquery, OuterRef, Count, IntegerField - from django.db.models.functions import Coalesce from django.db.models.expressions import RawSQL from django.utils.translation import get_language - descendant_hadis_subquery = Subquery( - Hadis.objects.filter( - category__tree_id=OuterRef('tree_id'), - category__lft__gte=OuterRef('lft'), - category__rght__lte=OuterRef('rght'), - status=True - ).values('category__tree_id').annotate(cnt=Count('id')).values('cnt')[:1], - output_field=IntegerField() - ) - queryset = queryset.annotate(total_content_count=Coalesce(descendant_hadis_subquery, 0)) + if 'total_content_count' not in queryset.query.annotations: + queryset = annotate_and_prune_empty_categories(queryset) ordering_param = ( request.query_params.get('ordering') or @@ -395,18 +438,13 @@ class HadisCategorySelectBySectView(ListAPIView): except HadisCategory.DoesNotExist: return HadisCategory.objects.none() - from django.db.models import Count, Q - # Return children of this category that have either active hadiths, children, or are quran + # Return children of this category that have active content in their subtree or Quran verse queryset = HadisCategory.objects.filter( parent=parent_category, sect__sect_type=sect_type, sect__is_active=True - ).annotate( - active_hadis_count=Count('hadis', filter=Q(hadis__status=True)), - children_total_count=Count('children') - ).filter( - Q(active_hadis_count__gt=0) | Q(children_total_count__gt=0) | Q(source_type=HadisCategory.SourceType.QURAN) ) + queryset = annotate_and_prune_empty_categories(queryset) return apply_category_ordering_and_counts(queryset, self.request) @@ -464,19 +502,14 @@ class HadisCategorySelectBySectSourceView(ListAPIView): except HadisCategory.DoesNotExist: return HadisCategory.objects.none() - from django.db.models import Count, Q - # Return children of this category, filtered by source_type and excluding empty leaves + # Return children of this category, filtered by source_type and excluding empty subtrees queryset = HadisCategory.objects.filter( parent=parent_category, sect__sect_type=sect_type, sect__is_active=True, source_type=source_type - ).annotate( - active_hadis_count=Count('hadis', filter=Q(hadis__status=True)), - children_total_count=Count('children') - ).filter( - Q(active_hadis_count__gt=0) | Q(children_total_count__gt=0) | Q(source_type=HadisCategory.SourceType.QURAN) ) + queryset = annotate_and_prune_empty_categories(queryset) return apply_category_ordering_and_counts(queryset, self.request) class CategoriesView(ListAPIView): @@ -490,15 +523,10 @@ class CategoriesView(ListAPIView): return self.list(request, *args, **kwargs) def get_queryset(self): - from django.db.models import Count, Q queryset = HadisCategory.objects.filter( sect__is_active=True - ).annotate( - active_hadis_count=Count('hadis', filter=Q(hadis__status=True)), - children_total_count=Count('children') - ).filter( - Q(active_hadis_count__gt=0) | Q(children_total_count__gt=0) | Q(source_type=HadisCategory.SourceType.QURAN) ) + queryset = annotate_and_prune_empty_categories(queryset) search_query = (self.request.query_params.get('search') or '').strip() if search_query: from utils.search_parser import build_advanced_search_q @@ -525,16 +553,11 @@ class CategoriesBySectView(ListAPIView): def get_queryset(self): sect_type = self.kwargs.get('sect_type') - from django.db.models import Count, Q queryset = HadisCategory.objects.filter( sect__sect_type=sect_type, sect__is_active=True - ).annotate( - active_hadis_count=Count('hadis', filter=Q(hadis__status=True)), - children_total_count=Count('children') - ).filter( - Q(active_hadis_count__gt=0) | Q(children_total_count__gt=0) | Q(source_type=HadisCategory.SourceType.QURAN) ) + queryset = annotate_and_prune_empty_categories(queryset) search_query = (self.request.query_params.get('search') or '').strip() if search_query: from utils.search_parser import build_advanced_search_q @@ -653,9 +676,9 @@ class HadisCategoryXMindView(APIView): ).order_by('order') hadiths = Hadis.objects.filter( - category=category, + Q(category=category) | Q(categories=category), status=True - ).order_by('number').prefetch_related( + ).distinct().order_by('number').prefetch_related( Prefetch('transmitters', queryset=transmitter_qs), 'narrator_layers', 'hadiscorrection_set' diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py index 2750d0f..079e54a 100644 --- a/apps/hadis/views/hadis.py +++ b/apps/hadis/views/hadis.py @@ -142,9 +142,9 @@ class HadisSyncView(ListAPIView): category_id = self.request.query_params.get('category_id') category_slug = self.request.query_params.get('category_slug') if category_id: - qs = qs.filter(category_id=category_id) + qs = qs.filter(Q(category_id=category_id) | Q(categories__id=category_id)).distinct() elif category_slug: - qs = qs.filter(category__slug=category_slug) + qs = qs.filter(Q(category__slug=category_slug) | Q(categories__slug=category_slug)).distinct() return ( qs @@ -521,13 +521,17 @@ class HadisMainListView(ListAPIView): if sect_filter: sects = [s.strip().lower() for s in sect_filter.split(',') if s.strip()] if sects: - queryset = queryset.filter(category__sect__sect_type__in=sects) + queryset = queryset.filter( + Q(category__sect__sect_type__in=sects) | Q(categories__sect__sect_type__in=sects) + ) # Apply source_type filter (e.g. hadith, quran, history, fatwa, quote) if source_type_filter: source_types = [st.strip().lower() for st in source_type_filter.split(',') if st.strip()] if source_types: - queryset = queryset.filter(category__source_type__in=source_types) + queryset = queryset.filter( + Q(category__source_type__in=source_types) | Q(categories__source_type__in=source_types) + ) # Apply status filter (by slug or title) if status_filter: @@ -555,7 +559,10 @@ class HadisMainListView(ListAPIView): if category_filter: cat_items = [c.strip() for c in category_filter.split(',') if c.strip()] queryset = queryset.filter( - Q(category__slug__in=cat_items) | Q(category__title__icontains=category_filter) + Q(category__slug__in=cat_items) | + Q(category__title__icontains=category_filter) | + Q(categories__slug__in=cat_items) | + Q(categories__title__icontains=category_filter) ) # Apply tag filter