Browse Source

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
master
Mohsen Taba 2 weeks ago
parent
commit
058929fb71
  1. 43
      apps/hadis/serializers/category.py
  2. 10
      apps/hadis/urls.py
  3. 175
      apps/hadis/views/category.py
  4. 17
      apps/hadis/views/hadis.py

43
apps/hadis/serializers/category.py

@ -253,19 +253,28 @@ class HadisCategorySelectSerializer(serializers.ModelSerializer):
if has_active_children: if has_active_children:
return False return False
if hasattr(obj, 'active_hadis_count'):
return obj.active_hadis_count > 0
# If no active children, check if has hadis # 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): def get_children_count(self, obj):
if hasattr(obj, 'total_content_count'): if hasattr(obj, 'total_content_count'):
return obj.total_content_count return obj.total_content_count
from django.db.models import Q
family_tree = obj.get_descendants(include_self=True) 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): def get_hadis_count(self, obj):
if hasattr(obj, 'active_hadis_count'): if hasattr(obj, 'active_hadis_count'):
return 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, cannot have hadis
if has_active_children: if has_active_children:
return False return False
if hasattr(obj, 'active_hadis_count'):
return obj.active_hadis_count > 0
# If no active children, check if has hadis # 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): def get_children_count(self, obj):
if hasattr(obj, 'total_content_count'): if hasattr(obj, 'total_content_count'):
return obj.total_content_count return obj.total_content_count
from django.db.models import Q
family_tree = obj.get_descendants(include_self=True) 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): def get_hadis_count(self, obj):
if hasattr(obj, 'active_hadis_count'): if hasattr(obj, 'active_hadis_count'):
return 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): class CategorySerializer(serializers.ModelSerializer):
sect_id = serializers.IntegerField(source='sect.id', read_only=True) sect_id = serializers.IntegerField(source='sect.id', read_only=True)
@ -334,16 +351,24 @@ class CategorySerializer(serializers.ModelSerializer):
def get_children_count(self, obj): def get_children_count(self, obj):
if hasattr(obj, 'total_content_count'): if hasattr(obj, 'total_content_count'):
return obj.total_content_count return obj.total_content_count
from django.db.models import Q
family_tree = obj.get_descendants(include_self=True) 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): 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): def get_hadis_count(self, obj):
if hasattr(obj, 'active_hadis_count'): if hasattr(obj, 'active_hadis_count'):
return 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): # def get_title(self,obj):
# # ✅ Get language from request # # ✅ Get language from request

10
apps/hadis/urls.py

@ -85,7 +85,7 @@ urlpatterns = [
path('pinned-collections/', cached_view(PinnedHadisCollectionListView.as_view()), name='pinned-hadis-collection-list'), 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('collections/', cached_view(HadisCollectionListView.as_view()), name='hadis-collection-list'),
path('sync/sects/', cached_view(HadisCategorySectListView.as_view()), name='hadis-sect-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/hadis/', cached_view(HadisSyncView.as_view()), name='hadis-sync'),
path('sync/narrators/', cached_view(TransmitterSyncView.as_view()), name='transmitter-sync'), path('sync/narrators/', cached_view(TransmitterSyncView.as_view()), name='transmitter-sync'),
path('sync/references/', cached_view(BookReferenceSyncView.as_view()), name='reference-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'), path('tags/', cached_view(HadisTagListView.as_view()), name='hadis-tag-list'),
# Category paths (more specific first) # Category paths (more specific first)
path('categories/tree/', cached_view(HadisCategoryTreeNormalView.as_view()), name='hadis-category-tree-normal'),
path('categories/<str:sect_type>/<str:slug>/<str:source_type>/', cached_view(HadisCategorySelectBySectSourceView.as_view()), name='categories-tree-by-sect-source'),
path('categories/<str:sect_type>/<str:slug>/', cached_view(HadisCategorySelectBySectView.as_view()), name='categories-tree-by-sect'),
path('categories/tree/', HadisCategoryTreeNormalView.as_view(), name='hadis-category-tree-normal'),
path('categories/<str:sect_type>/<str:slug>/<str:source_type>/', HadisCategorySelectBySectSourceView.as_view(), name='categories-tree-by-sect-source'),
path('categories/<str:sect_type>/<str:slug>/', HadisCategorySelectBySectView.as_view(), name='categories-tree-by-sect'),
path('categories/<str:sect_type>/', CategoriesBySectView.as_view(), name='categories-by-sect'), path('categories/<str:sect_type>/', 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 # Hadis paths
path('category/<str:category_slug>/xmind/', HadisCategoryXMindView.as_view(), name='hadis-category-xmind'), # ← Must be before other category paths path('category/<str:category_slug>/xmind/', HadisCategoryXMindView.as_view(), name='hadis-category-xmind'), # ← Must be before other category paths

175
apps/hadis/views/category.py

@ -55,35 +55,43 @@ class HadisCategoryTreeView(ListAPIView):
def get_queryset(self): 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 ( return (
HadisCategory.objects HadisCategory.objects
.filter(sect__is_active=True) .filter(sect__is_active=True)
.select_related('sect') .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') .order_by('sect__order', 'tree_id', 'lft', 'order')
) )
def list(self, request, *args, **kwargs): 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 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') .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) # 👇 2. Map Quran Data (Bulk fetch to prevent N+1 Queries)
quran_data_map = {} quran_data_map = {}
@ -112,24 +120,24 @@ class HadisCategoryTreeView(ListAPIView):
children_map[cat.parent_id] = [] children_map[cat.parent_id] = []
children_map[cat.parent_id].append(cat) 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, []): 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 # 5. Build grouped structure
grouped_data = {} grouped_data = {}
@ -265,6 +273,56 @@ class HadisCategoryTreeView(ListAPIView):
return None 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): class HadisCategoryTreeNormalView(ListAPIView):
""" """
Normal (paginated) tree view for HadisCategory. Normal (paginated) tree view for HadisCategory.
@ -279,33 +337,18 @@ class HadisCategoryTreeNormalView(ListAPIView):
return self.list(request, *args, **kwargs) return self.list(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
from django.db.models import Count, Q
return HadisCategory.objects.filter(
qs = HadisCategory.objects.filter(
parent__isnull=True, parent__isnull=True,
sect__is_active=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): 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.db.models.expressions import RawSQL
from django.utils.translation import get_language 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 = ( ordering_param = (
request.query_params.get('ordering') or request.query_params.get('ordering') or
@ -395,18 +438,13 @@ class HadisCategorySelectBySectView(ListAPIView):
except HadisCategory.DoesNotExist: except HadisCategory.DoesNotExist:
return HadisCategory.objects.none() 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( queryset = HadisCategory.objects.filter(
parent=parent_category, parent=parent_category,
sect__sect_type=sect_type, sect__sect_type=sect_type,
sect__is_active=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)
) )
queryset = annotate_and_prune_empty_categories(queryset)
return apply_category_ordering_and_counts(queryset, self.request) return apply_category_ordering_and_counts(queryset, self.request)
@ -464,19 +502,14 @@ class HadisCategorySelectBySectSourceView(ListAPIView):
except HadisCategory.DoesNotExist: except HadisCategory.DoesNotExist:
return HadisCategory.objects.none() 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( queryset = HadisCategory.objects.filter(
parent=parent_category, parent=parent_category,
sect__sect_type=sect_type, sect__sect_type=sect_type,
sect__is_active=True, sect__is_active=True,
source_type=source_type 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) return apply_category_ordering_and_counts(queryset, self.request)
class CategoriesView(ListAPIView): class CategoriesView(ListAPIView):
@ -490,15 +523,10 @@ class CategoriesView(ListAPIView):
return self.list(request, *args, **kwargs) return self.list(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
from django.db.models import Count, Q
queryset = HadisCategory.objects.filter( queryset = HadisCategory.objects.filter(
sect__is_active=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)
) )
queryset = annotate_and_prune_empty_categories(queryset)
search_query = (self.request.query_params.get('search') or '').strip() search_query = (self.request.query_params.get('search') or '').strip()
if search_query: if search_query:
from utils.search_parser import build_advanced_search_q from utils.search_parser import build_advanced_search_q
@ -525,16 +553,11 @@ class CategoriesBySectView(ListAPIView):
def get_queryset(self): def get_queryset(self):
sect_type = self.kwargs.get('sect_type') sect_type = self.kwargs.get('sect_type')
from django.db.models import Count, Q
queryset = HadisCategory.objects.filter( queryset = HadisCategory.objects.filter(
sect__sect_type=sect_type, sect__sect_type=sect_type,
sect__is_active=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)
) )
queryset = annotate_and_prune_empty_categories(queryset)
search_query = (self.request.query_params.get('search') or '').strip() search_query = (self.request.query_params.get('search') or '').strip()
if search_query: if search_query:
from utils.search_parser import build_advanced_search_q from utils.search_parser import build_advanced_search_q
@ -653,9 +676,9 @@ class HadisCategoryXMindView(APIView):
).order_by('order') ).order_by('order')
hadiths = Hadis.objects.filter( hadiths = Hadis.objects.filter(
category=category,
Q(category=category) | Q(categories=category),
status=True status=True
).order_by('number').prefetch_related(
).distinct().order_by('number').prefetch_related(
Prefetch('transmitters', queryset=transmitter_qs), Prefetch('transmitters', queryset=transmitter_qs),
'narrator_layers', 'narrator_layers',
'hadiscorrection_set' 'hadiscorrection_set'

17
apps/hadis/views/hadis.py

@ -142,9 +142,9 @@ class HadisSyncView(ListAPIView):
category_id = self.request.query_params.get('category_id') category_id = self.request.query_params.get('category_id')
category_slug = self.request.query_params.get('category_slug') category_slug = self.request.query_params.get('category_slug')
if category_id: 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: 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 ( return (
qs qs
@ -521,13 +521,17 @@ class HadisMainListView(ListAPIView):
if sect_filter: if sect_filter:
sects = [s.strip().lower() for s in sect_filter.split(',') if s.strip()] sects = [s.strip().lower() for s in sect_filter.split(',') if s.strip()]
if sects: 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) # Apply source_type filter (e.g. hadith, quran, history, fatwa, quote)
if source_type_filter: if source_type_filter:
source_types = [st.strip().lower() for st in source_type_filter.split(',') if st.strip()] source_types = [st.strip().lower() for st in source_type_filter.split(',') if st.strip()]
if source_types: 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) # Apply status filter (by slug or title)
if status_filter: if status_filter:
@ -555,7 +559,10 @@ class HadisMainListView(ListAPIView):
if category_filter: if category_filter:
cat_items = [c.strip() for c in category_filter.split(',') if c.strip()] cat_items = [c.strip() for c in category_filter.split(',') if c.strip()]
queryset = queryset.filter( 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 # Apply tag filter

Loading…
Cancel
Save