From f56736a23c6aa7fe3ff7da9c44d6aa305a5a3272 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Wed, 9 Sep 2026 09:58:37 +0330 Subject: [PATCH 1/5] feat(corrections): apis updated for hadith corrections --- apps/hadis/serializers/hadis.py | 189 +++++++++++++++++++- apps/hadis/serializers/serializers_admin.py | 158 +++++++++++++++- apps/hadis/signals.py | 3 +- apps/hadis/urls.py | 5 +- apps/hadis/views_admin.py | 19 ++ 5 files changed, 363 insertions(+), 11 deletions(-) diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py index 9185ac8..d634347 100644 --- a/apps/hadis/serializers/hadis.py +++ b/apps/hadis/serializers/hadis.py @@ -1379,9 +1379,21 @@ class NarratorRelationSerializer(serializers.ModelSerializer): # --- سریالایزرهای کمکی تصاویر رفرنس --- class CorrectionReferenceImageSerializer(serializers.ModelSerializer): + image = serializers.SerializerMethodField() + thumbnail = serializers.SerializerMethodField() + class Meta: model = CorrectionReferenceImage - fields = ['id', 'image', 'priority'] + fields = ['id', 'image', 'thumbnail', 'priority'] + + def get_image(self, obj): + if obj.image: + request = self.context.get('request') + return absolute_https_url(obj.image.url, request) if request else absolute_https_url(obj.image.url) + return None + + def get_thumbnail(self, obj): + return self.get_image(obj) class InterpretationReferenceImageSerializer(serializers.ModelSerializer): class Meta: @@ -1417,11 +1429,22 @@ class BaseDetailedReferenceSerializer(serializers.ModelSerializer): return [] def get_edition_info(self, obj): - if obj.edition: + edition = obj.edition + if not edition and getattr(obj, "book_volume", None) and obj.book_volume.edition: + edition = obj.book_volume.edition + if not edition and getattr(obj, "book_reference", None) and hasattr(obj.book_reference, "editions"): + edition = obj.book_reference.editions.first() + if edition: + request = self.context.get("request") + pub = edition.publisher + if isinstance(pub, list): + pub = get_localized_text(pub, request) return { - "id": obj.edition.id, - "edition_number": obj.edition.edition_number, - "publisher": obj.edition.publisher + "id": edition.id, + "edition_number": edition.edition_number, + "publisher": pub or "", + "year_of_publication": edition.year_of_publication or "", + "number_of_volumes": edition.number_of_volumes, } return None @@ -1434,10 +1457,164 @@ class BaseDetailedReferenceSerializer(serializers.ModelSerializer): return None class DetailedCorrectionReferenceSerializer(BaseDetailedReferenceSerializer): + book = serializers.SerializerMethodField() + author = serializers.SerializerMethodField() + edition = serializers.SerializerMethodField() + open_book_url = serializers.SerializerMethodField() + citation_summary = serializers.SerializerMethodField() images = CorrectionReferenceImageSerializer(many=True, read_only=True) + images_count = serializers.SerializerMethodField() + class Meta: model = CorrectionReference - fields = ['id', 'book_title', 'book_authors', 'edition_info', 'volume_info', 'hadith_number', 'volume', 'pages', 'address', 'url', 'images'] + fields = [ + 'id', + 'book', + 'author', + 'edition', + 'volume_info', + 'hadith_number', + 'volume', + 'pages', + 'address', + 'url', + 'open_book_url', + 'citation_summary', + 'images', + 'images_count', + 'book_title', + 'book_authors', + 'edition_info', + ] + + def get_book(self, obj): + if obj.book_reference: + request = self.context.get('request') + first_ed = obj.edition + if not first_ed and getattr(obj, "book_volume", None) and obj.book_volume.edition: + first_ed = obj.book_volume.edition + if not first_ed and hasattr(obj.book_reference, "editions"): + first_ed = obj.book_reference.editions.first() + + pub = getattr(obj.book_reference, "publisher", None) + if pub is None and first_ed: + pub = first_ed.publisher + if isinstance(pub, list): + pub = get_localized_text(pub, request) + + year = getattr(obj.book_reference, "year_of_publication", None) + if not year and first_ed: + year = first_ed.year_of_publication + + num_vols = getattr(obj.book_reference, "number_of_volumes", None) + if num_vols is None and first_ed: + num_vols = first_ed.number_of_volumes + if num_vols is None and hasattr(obj.book_reference, "volumes"): + num_vols = obj.book_reference.volumes.count() or None + + return { + 'id': obj.book_reference.id, + 'slug': obj.book_reference.slug, + 'title': get_localized_text(obj.book_reference.title, request), + 'publisher': pub or '', + 'year_of_publication': year or '', + 'number_of_volumes': num_vols, + } + return None + + def get_author(self, obj): + if obj.book_reference and obj.book_reference.author: + author = obj.book_reference.author + request = self.context.get('request') + return { + 'id': author.id, + 'slug': author.slug, + 'name': get_localized_text(author.name, request) + } + return None + + def get_edition(self, obj): + edition = obj.edition + if not edition and getattr(obj, "book_volume", None) and obj.book_volume.edition: + edition = obj.book_volume.edition + if not edition and getattr(obj, "book_reference", None) and hasattr(obj.book_reference, "editions"): + edition = obj.book_reference.editions.first() + if edition: + request = self.context.get('request') + pub = edition.publisher + if isinstance(pub, list): + pub = get_localized_text(pub, request) + return { + 'id': edition.id, + 'edition_number': edition.edition_number, + 'publisher': pub or '', + 'year_of_publication': edition.year_of_publication or '', + 'number_of_volumes': edition.number_of_volumes, + } + return None + + def get_open_book_url(self, obj): + request = self.context.get('request') + if obj.url: + return obj.url + if obj.book_volume and obj.book_volume.file: + return absolute_https_url(obj.book_volume.file.url, request) if request else obj.book_volume.file.url + if obj.edition and obj.edition.source_url: + return obj.edition.source_url + if obj.book_reference and obj.book_reference.slug: + return f"/arguments/sources/{obj.book_reference.slug}" + return None + + volume = serializers.SerializerMethodField() + pages = serializers.SerializerMethodField() + hadith_number = serializers.SerializerMethodField() + + def get_volume(self, obj): + if obj.volume and str(obj.volume).strip().lower() != "none": + return obj.volume + if obj.book_volume and obj.book_volume.title: + return obj.book_volume.title + return None + + def get_pages(self, obj): + if obj.pages and str(obj.pages).strip().lower() != "none": + return obj.pages + return None + + def get_hadith_number(self, obj): + if obj.hadith_number and str(obj.hadith_number).strip().lower() != "none": + return obj.hadith_number + return None + + def get_citation_summary(self, obj): + parts = [] + vol = self.get_volume(obj) + pg = self.get_pages(obj) + num = self.get_hadith_number(obj) + if vol: + vol_str = str(vol).strip() + if vol_str.startswith("جلد"): + parts.append(vol_str) + else: + parts.append(f"جلد {vol_str}") + if pg: + pg_str = str(pg).strip() + if pg_str.startswith("ص") or pg_str.startswith("صفحه"): + parts.append(pg_str) + else: + parts.append(f"صفحه {pg_str}") + if num: + num_str = str(num).strip() + if num_str.startswith("ح") or num_str.startswith("حدیث") or num_str.startswith("شماره"): + parts.append(num_str) + else: + parts.append(f"شماره حدیث {num_str}") + return " • ".join(parts) if parts else "" + + def get_images_count(self, obj): + if hasattr(obj, '_prefetched_objects_cache') and 'images' in obj._prefetched_objects_cache: + return len(obj.images.all()) + return obj.images.count() class DetailedInterpretationReferenceSerializer(BaseDetailedReferenceSerializer): images = InterpretationReferenceImageSerializer(many=True, read_only=True) diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index a6cad2c..ff37bc1 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/apps/hadis/serializers/serializers_admin.py @@ -13,7 +13,7 @@ from ..models import ( OriginalTextReference, OriginalTextReferenceImage, ContentRelease ) -from .category import LocalizedField +from .category import LocalizedField, get_localized_text def safe_copy_data(data): @@ -489,11 +489,163 @@ def save_relation_reference_data(instance, validated_data, request=None, is_corr InterpretationReferenceImage.objects.create(reference=ref, image=img) +class AdminCorrectionReferenceSerializer(serializers.ModelSerializer): + book_reference_detail = serializers.SerializerMethodField(read_only=True) + edition_detail = serializers.SerializerMethodField(read_only=True) + book_volume_detail = serializers.SerializerMethodField(read_only=True) + images = serializers.SerializerMethodField(read_only=True) + delete_image_ids = serializers.ListField( + child=serializers.IntegerField(), + required=False, + write_only=True + ) + + class Meta: + model = CorrectionReference + fields = [ + "id", + "correction", + "book_reference", + "book_reference_detail", + "edition", + "edition_detail", + "book_volume", + "book_volume_detail", + "hadith_number", + "volume", + "pages", + "address", + "url", + "images", + "delete_image_ids", + ] + + def get_book_reference_detail(self, obj): + if obj.book_reference: + request = self.context.get("request") + author = obj.book_reference.author + first_ed = obj.book_reference.editions.first() if hasattr(obj.book_reference, "editions") else None + pub = getattr(obj.book_reference, "publisher", None) + if pub is None and first_ed: + pub = first_ed.publisher + if isinstance(pub, list): + pub = get_localized_text(pub, request) + year = getattr(obj.book_reference, "year_of_publication", None) + if not year and first_ed: + year = first_ed.year_of_publication + num_vols = getattr(obj.book_reference, "number_of_volumes", None) + if num_vols is None and first_ed: + num_vols = first_ed.number_of_volumes + return { + "id": obj.book_reference.id, + "title": get_localized_text(obj.book_reference.title, request), + "slug": obj.book_reference.slug, + "publisher": pub or "", + "year_of_publication": year or "", + "number_of_volumes": num_vols, + "has_editions": getattr(obj.book_reference, "has_editions", False), + "author": { + "id": author.id, + "name": get_localized_text(author.name, request), + "slug": author.slug + } if author else None + } + return None + + def get_edition_detail(self, obj): + if obj.edition: + request = self.context.get("request") + pub = obj.edition.publisher + if isinstance(pub, list): + pub = get_localized_text(pub, request) + return { + "id": obj.edition.id, + "edition_number": obj.edition.edition_number, + "publisher": pub, + "year_of_publication": obj.edition.year_of_publication, + "number_of_volumes": obj.edition.number_of_volumes, + } + return None + + def get_book_volume_detail(self, obj): + if obj.book_volume: + return { + "id": obj.book_volume.id, + "title": obj.book_volume.title, + } + return None + + def get_images(self, obj): + request = self.context.get("request") + return [ + { + "id": img.id, + "image": absolute_https_url(img.image.url, request) if request and img.image else (img.image.url if img.image else None), + "thumbnail": absolute_https_url(img.image.url, request) if request and img.image else (img.image.url if img.image else None), + "priority": img.priority, + } + for img in obj.images.all() + ] + + def to_internal_value(self, data): + import json + data = safe_copy_data(data) + book_ref = data.get("book_reference") + if book_ref in ("", "none", "null"): + data["book_reference"] = None + + edition = data.get("edition") + if edition in ("", "none", "null"): + data["edition"] = None + + book_vol = data.get("book_volume") + if book_vol in ("", "none", "null"): + data["book_volume"] = None + + delete_ids = data.get("delete_image_ids") + if delete_ids: + if isinstance(delete_ids, str): + try: + data["delete_image_ids"] = json.loads(delete_ids) + except ValueError: + data["delete_image_ids"] = [int(x) for x in delete_ids.split(",") if x.strip().isdigit()] + elif isinstance(delete_ids, list): + parsed = [] + for x in delete_ids: + if isinstance(x, str) and x.strip().isdigit(): + parsed.append(int(x)) + elif isinstance(x, int): + parsed.append(x) + data["delete_image_ids"] = parsed + return super().to_internal_value(data) + + def create(self, validated_data): + delete_image_ids = validated_data.pop("delete_image_ids", []) + instance = super().create(validated_data) + self._handle_images(instance, delete_image_ids) + return instance + + def update(self, instance, validated_data): + delete_image_ids = validated_data.pop("delete_image_ids", []) + instance = super().update(instance, validated_data) + self._handle_images(instance, delete_image_ids) + return instance + + def _handle_images(self, instance, delete_image_ids): + request = self.context.get("request") + if delete_image_ids: + instance.images.filter(id__in=delete_image_ids).delete() + + if request: + new_images = request.FILES.getlist("uploaded_images") or request.FILES.getlist("uploaded_images[]") or request.FILES.getlist("images") + for img in new_images: + CorrectionReferenceImage.objects.create(reference=instance, image=img) class AdminHadisCorrectionSerializer(serializers.ModelSerializer): hadis_detail = AdminHadisBriefSerializer(source="hadis", read_only=True) bookmark_count = serializers.IntegerField(source="annotated_bookmark_count", read_only=True) + references = AdminCorrectionReferenceSerializer(many=True, read_only=True) # Writable relation fields book_reference_id = serializers.PrimaryKeyRelatedField( @@ -529,10 +681,11 @@ class AdminHadisCorrectionSerializer(serializers.ModelSerializer): "book_reference_id", "address", "delete_image_ids", + "references", "created_at", "updated_at", ] - read_only_fields = ["id", "slug", "bookmark_count", "created_at", "updated_at"] + read_only_fields = ["id", "slug", "bookmark_count", "references", "created_at", "updated_at"] def to_internal_value(self, data): import json @@ -586,6 +739,7 @@ class AdminHadisCorrectionSerializer(serializers.ModelSerializer): } for img in images ] + ret["references"] = AdminCorrectionReferenceSerializer(instance.references.all(), many=True, context=self.context).data return ret def create(self, validated_data): diff --git a/apps/hadis/signals.py b/apps/hadis/signals.py index 6e9d506..6463b5a 100644 --- a/apps/hadis/signals.py +++ b/apps/hadis/signals.py @@ -12,7 +12,8 @@ TARGET_MODELS = [ Hadis, HadisCategory, HadisCollection, HadisCorrection, HadisInCollection, HadisReference, HadisSect, HadisStatus, HadisTag, HadisTransmitter, Transmitters, TransmitterOpinion, TransmitterReliability, TransmitterOriginalText, ReferenceImage, BookReference, BookAttribute, BookAuthor, BookReferenceImage, - NarratorLayer, OpinionStatus, ContentRelease + NarratorLayer, OpinionStatus, ContentRelease, + CorrectionReference, BookEdition, BookVolume, CorrectionReferenceImage ] diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index 81c4a3a..c1dd5d2 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -14,7 +14,7 @@ from .views.reference_v2 import BookReferenceV2DetailView, BookReferenceV2SyncVi from .views.version import ContentReleaseSyncView from .views.info import HadisInfoView from .views_admin import ( - AdminHadisCategoryViewSet, AdminHadisViewSet, AdminHadisCorrectionViewSet, AdminHadisInterpretationViewSet, AdminHadisStatusViewSet, AdminHadisTagViewSet, AdminHadisCollectionViewSet, + AdminHadisCategoryViewSet, AdminHadisViewSet, AdminHadisCorrectionViewSet, AdminCorrectionReferenceViewSet, AdminHadisInterpretationViewSet, AdminHadisStatusViewSet, AdminHadisTagViewSet, AdminHadisCollectionViewSet, AdminBookSubjectAreaViewSet, AdminBookTypeViewSet, AdminBookTagViewSet, AdminBookAuthorViewSet, AdminBookAttributeViewSet, AdminBookReferenceImageViewSet, AdminHadisReferenceImageViewSet, AdminBookReferenceDocumentViewSet, AdminBookReferenceViewSet, AdminTransmitterViewSet, AdminTransmitterReliabilityViewSet, DovodiBookSubjectAreaViewSet, DovodiBookTypeViewSet, DovodiNarratorLayerViewSet, DovodiTransmitterReliabilityViewSet, DovodiOpinionStatusViewSet, @@ -37,6 +37,7 @@ admin_router.register(r'quran-verses', AdminCategoryQuranVerseViewSet, basename= admin_router.register(r'dovodi-sects', DovodiHadisSectViewSet, basename='dovodi-hadis-sects') admin_router.register(r'dovodi-categories', DovodiHadisCategoryViewSet, basename='dovodi-hadis-categories') admin_router.register(r'corrections', AdminHadisCorrectionViewSet, basename='admin-hadis-corrections') +admin_router.register(r'correction-references', AdminCorrectionReferenceViewSet, basename='admin-correction-references') admin_router.register(r'interpretations', AdminHadisInterpretationViewSet, basename='admin-hadis-interpretations') admin_router.register(r'statuses', AdminHadisStatusViewSet, basename='admin-hadis-statuses') admin_router.register(r'tags', AdminHadisTagViewSet, basename='admin-hadis-tags') @@ -141,7 +142,7 @@ urlpatterns = [ # Interpretation Detail Endpoint (تفسیر بر اساس آی‌دی) path('interpretations//', cached_view(HadisInterpretationDetailView.as_view()), name='interpretation-detail'), # Correction Detail Endpoint (تصحیح بر اساس اسلاگ) - path('corrections//', cached_view(HadisCorrectionDetailView.as_view()), name='correction-detail'), + path('corrections//', HadisCorrectionDetailView.as_view(), name='correction-detail'), # Original Text Detail Endpoint (متن اصلی راوی بر اساس اسلاگ) path('original-texts//', cached_view(TransmitterOriginalTextDetailView.as_view()), name='original-text-detail'), diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index 34cf8f3..330e7c8 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -13,6 +13,7 @@ from .models import ( Hadis, HadisCategory, HadisCollection, HadisInCollection, HadisCorrection, HadisInterpretation, HadisStatus, HadisTag, HadisSect, HadisTransmitter, HadisReference, ReferenceImage, BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, TransmitterOpinion, TransmitterOriginalText, + CorrectionReference, ContentRelease ) from .serializers.serializers_admin import ( @@ -21,6 +22,7 @@ from .serializers.serializers_admin import ( AdminHadisCategoryArchiveSerializer, AdminHadisCategoryArchiveDetailSerializer, AdminHadisCorrectionSerializer, + AdminCorrectionReferenceSerializer, AdminHadisDetailSerializer, AdminHadisInterpretationSerializer, AdminHadisCollectionSerializer, @@ -425,6 +427,23 @@ class AdminHadisCorrectionViewSet(ModelViewSet): return Response({"status": "success"}) +class AdminCorrectionReferenceViewSet(ModelViewSet): + serializer_class = AdminCorrectionReferenceSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + parser_classes = (MultiPartParser, FormParser, JSONParser) + pagination_class = OptionalPagination + + def get_queryset(self): + qs = CorrectionReference.objects.all().select_related( + "book_reference__author", "edition", "book_volume" + ).prefetch_related("images") + correction_id = self.request.query_params.get("correction_id") + if correction_id: + qs = qs.filter(correction_id=correction_id) + return qs.order_by("id") + + class AdminHadisInterpretationViewSet(ModelViewSet): serializer_class = AdminHadisInterpretationSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] From 2fb450007e26d9c5a6a4a1fad4bbe7e907ef4c09 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Wed, 9 Sep 2026 10:07:07 +0330 Subject: [PATCH 2/5] perf(hadis-sync): optimize prefetching for corrections and interpretations to eliminate N+1 queries --- apps/hadis/views/hadis.py | 42 +++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py index 5cc067b..fbb8a5f 100644 --- a/apps/hadis/views/hadis.py +++ b/apps/hadis/views/hadis.py @@ -10,7 +10,7 @@ from rest_framework.response import Response from django.db.models import Count from django.db.models import Prefetch from ..serializers.category import get_localized_text -from ..models import Transmitters, HadisCategory, Hadis, HadisCollection,HadisTransmitter , HadisCorrection ,HadisReference, HadisStatus ,ReferenceImage +from ..models import Transmitters, HadisCategory, Hadis, HadisCollection,HadisTransmitter , HadisCorrection ,HadisReference, HadisStatus ,ReferenceImage, CorrectionReference, HadisInterpretation, InterpretationReference from ..serializers import HadisListSerializer, HadisBasicSerializer, HadisDetailSerializer, HadisCollectionListSerializer, HadisSyncSerializer,HadisCorrectionSerializer,HadisTransmitterListSerializer , SimpleCategory, NarratorLayerSerializer , PinnedHadisCollectionSerializer, HadisSourceDetailsSerializer from ..docs import arguments_filters_swagger ,hadis_list_swagger, hadis_detail_swagger, hadis_collections_swagger, hadis_sync_swagger, hadis_transmitters_swagger, hadis_corrections_swagger, hadis_basic_swagger, hadis_main_list_swagger, hadis_layers_swagger from django.db.models import Q @@ -143,24 +143,44 @@ class HadisSyncView(ListAPIView): ) ) ), - # 👇 اضافه شدن واکشی عمیق برای تصحیحات + # 👇 اضافه شدن واکشی عمیق برای تصحیحات (بدون N+1) Prefetch( 'hadiscorrection_set', queryset=HadisCorrection.objects.select_related('hadis').prefetch_related( - 'references__book_reference__author', - 'references__edition', - 'references__book_volume', - 'references__images' + Prefetch( + 'references', + queryset=CorrectionReference.objects.select_related( + 'book_reference', + 'book_reference__author', + 'edition', + 'book_volume', + 'book_volume__edition' + ).prefetch_related( + 'images', + 'book_reference__editions', + 'book_reference__volumes' + ) + ) ) ), - # 👇 اضافه شدن واکشی عمیق برای تفاسیر + # 👇 اضافه شدن واکشی عمیق برای تفاسیر (بدون N+1) Prefetch( 'category__interpretations', queryset=HadisInterpretation.objects.select_related('category').prefetch_related( - 'references__book_reference__author', - 'references__edition', - 'references__book_volume', - 'references__images' + Prefetch( + 'references', + queryset=InterpretationReference.objects.select_related( + 'book_reference', + 'book_reference__author', + 'edition', + 'book_volume', + 'book_volume__edition' + ).prefetch_related( + 'images', + 'book_reference__editions', + 'book_reference__volumes' + ) + ) ) ), ) From 2a44f0cf042add075d35894673fe11db7f4895a7 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Wed, 9 Sep 2026 11:07:27 +0330 Subject: [PATCH 3/5] feat(hadis): add multi-category and hadith filtering to category corrections endpoint --- apps/hadis/views/hadis.py | 117 +++++++++++++++++++++++++++++++++----- 1 file changed, 104 insertions(+), 13 deletions(-) diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py index fbb8a5f..290e52d 100644 --- a/apps/hadis/views/hadis.py +++ b/apps/hadis/views/hadis.py @@ -690,11 +690,13 @@ class CategoryHadisCorrectionsView(ListAPIView): @swagger_auto_schema( operation_summary="Get Category Hadis Corrections", - operation_description="Returns all text corrections across all hadiths belonging to a specific category, including hadis_info for each correction.", + operation_description="Returns all text corrections across all hadiths belonging to a specific category, including hadis_info for each correction. Supports filtering by multiple hadiths and/or categories.", tags=['Dobodbi - Hadis (V2)'], manual_parameters=[ openapi.Parameter('search', openapi.IN_QUERY, description="Search in text or narrator", type=openapi.TYPE_STRING), - openapi.Parameter('hadis_slug', openapi.IN_QUERY, description="Filter corrections by specific hadith slug", type=openapi.TYPE_STRING), + openapi.Parameter('hadis_slug', openapi.IN_QUERY, description="Filter corrections by specific hadith slug (comma-separated for multiple)", type=openapi.TYPE_STRING), + openapi.Parameter('hadis_slugs', openapi.IN_QUERY, description="Filter corrections by multiple hadith slugs (comma-separated)", type=openapi.TYPE_STRING), + openapi.Parameter('category_slugs', openapi.IN_QUERY, description="Filter corrections by multiple category slugs (comma-separated)", type=openapi.TYPE_STRING), openapi.Parameter('is_bookmark', openapi.IN_QUERY, description="Filter only bookmarked corrections (requires auth)", type=openapi.TYPE_BOOLEAN), ] ) @@ -704,12 +706,52 @@ class CategoryHadisCorrectionsView(ListAPIView): def get_queryset(self): category_slug = self.kwargs.get('category_slug') try: - category = HadisCategory.objects.get(slug=category_slug) - category_ids = category.get_descendants(include_self=True).values_list('id', flat=True) - hadis_ids = Hadis.objects.filter( - category_id__in=category_ids, - status=True - ).values_list('id', flat=True) + category_ids = [] + if category_slug: + category = HadisCategory.objects.get(slug=category_slug) + category_ids = list(category.get_descendants(include_self=True).values_list('id', flat=True)) + + # Optional filter by multiple categories (selected subcategories or category slugs/IDs) + cat_param_keys = ['category_slugs', 'categories', 'category_slug'] + cat_slug_list = [] + for key in cat_param_keys: + vals = self.request.query_params.getlist(key) if hasattr(self.request.query_params, 'getlist') else [] + if not vals and key in self.request.query_params: + val = self.request.query_params.get(key) + if val: + vals = [val] if isinstance(val, str) else list(val) + for v in vals: + if isinstance(v, str): + cat_slug_list.extend([s.strip() for s in v.split(',') if s.strip()]) + elif isinstance(v, (list, tuple)): + cat_slug_list.extend(v) + elif v is not None: + cat_slug_list.append(str(v)) + + if cat_slug_list: + cat_filter = Q(slug__in=cat_slug_list) + id_list = [int(s) for s in cat_slug_list if str(s).isdigit()] + if id_list: + cat_filter |= Q(id__in=id_list) + + selected_categories = HadisCategory.objects.filter(cat_filter) + selected_cat_ids = set() + for sc in selected_categories: + selected_cat_ids.update(sc.get_descendants(include_self=True).values_list('id', flat=True)) + + if selected_cat_ids: + if category_ids: + category_ids = list(set(category_ids).intersection(selected_cat_ids)) + else: + category_ids = list(selected_cat_ids) + else: + return HadisCorrection.objects.none() + + hadis_filter_kwargs = {'status': True} + if category_ids: + hadis_filter_kwargs['category_id__in'] = category_ids + + hadis_ids = Hadis.objects.filter(**hadis_filter_kwargs).values_list('id', flat=True) queryset = HadisCorrection.objects.filter( hadis_id__in=hadis_ids @@ -731,10 +773,30 @@ class CategoryHadisCorrectionsView(ListAPIView): ) queryset = queryset.filter(search_conditions) - # Optional filter by specific hadith slug within this category - hadis_slug = self.request.query_params.get('hadis_slug', None) - if hadis_slug: - queryset = queryset.filter(hadis__slug=hadis_slug) + # Optional filter by specific hadith slug(s) or ID(s) + hadis_param_keys = ['hadis_slug', 'hadis_slugs', 'hadiths'] + hadis_slug_list = [] + for key in hadis_param_keys: + vals = self.request.query_params.getlist(key) if hasattr(self.request.query_params, 'getlist') else [] + if not vals and key in self.request.query_params: + val = self.request.query_params.get(key) + if val: + vals = [val] if isinstance(val, str) else list(val) + for v in vals: + if isinstance(v, str): + hadis_slug_list.extend([s.strip() for s in v.split(',') if s.strip()]) + elif isinstance(v, (list, tuple)): + hadis_slug_list.extend(v) + elif v is not None: + hadis_slug_list.append(str(v)) + + if hadis_slug_list: + hadis_query = Q(hadis__slug__in=hadis_slug_list) + id_list = [int(s) for s in hadis_slug_list if str(s).isdigit()] + if id_list: + hadis_query |= Q(hadis__id__in=id_list) + + queryset = queryset.filter(hadis_query) # Filter by bookmarks if provided is_bookmark = self.request.query_params.get('is_bookmark', '').lower() @@ -754,15 +816,44 @@ class CategoryHadisCorrectionsView(ListAPIView): def list(self, request, *args, **kwargs): response = super().list(request, *args, **kwargs) category_slug = self.kwargs.get('category_slug') - category_obj = HadisCategory.objects.filter(slug=category_slug).first() + category_obj = HadisCategory.objects.filter(slug=category_slug).first() if category_slug else None category_data = SimpleCategory(category_obj, context={'request': request}).data if category_obj else None + available_hadiths = [] + available_categories = [] + if category_obj: + cat_ids = category_obj.get_descendants(include_self=True).values_list('id', flat=True) + hadiths_in_cat = Hadis.objects.filter( + category_id__in=cat_ids, + status=True + ).order_by('number', 'id') + + for h in hadiths_in_cat: + h_title = get_localized_text(h.title, request) if hasattr(h, 'title') and h.title else f"Hadith {h.number}" + available_hadiths.append({ + "id": h.id, + "number": h.number, + "slug": h.slug, + "title": h_title, + }) + + subcats = category_obj.get_children() if hasattr(category_obj, 'get_children') else category_obj.children.all() + for sc in subcats: + sc_title = get_localized_text(sc.title, request) if hasattr(sc, 'title') and sc.title else sc.slug + available_categories.append({ + "id": sc.id, + "slug": sc.slug, + "title": sc_title, + }) + if isinstance(response.data, dict): ordered_data = { 'count': response.data.get('count', 0), 'next': response.data.get('next'), 'previous': response.data.get('previous'), 'current_category': category_data, + 'available_hadiths': available_hadiths, + 'available_categories': available_categories, 'results': response.data.get('results', []), } response.data = ordered_data From 790e5492ce1bd7ec3232d54e2f698a821da66796 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Wed, 9 Sep 2026 11:50:55 +0330 Subject: [PATCH 4/5] feat(hadis): support category ordering by most content and alphabetical title --- apps/hadis/serializers/category.py | 68 +++++++++++---------------- apps/hadis/views/category.py | 75 ++++++++++++++++++++++++++---- 2 files changed, 94 insertions(+), 49 deletions(-) diff --git a/apps/hadis/serializers/category.py b/apps/hadis/serializers/category.py index 78a49f2..b1b09c3 100644 --- a/apps/hadis/serializers/category.py +++ b/apps/hadis/serializers/category.py @@ -225,20 +225,15 @@ class HadisCategorySelectSerializer(serializers.ModelSerializer): return Hadis.objects.filter(category=obj, status=True).exists() def get_children_count(self, obj): - # """Get count of active children categories that have children or hadis""" - # children = obj.get_children().filter(sect=obj.sect) - # return len(children) - """ - Calculates the total number of Hadiths in this category - and all its descendants (sub-categories). - """ - # 1. Get all descendants of this category (including itself) + if hasattr(obj, 'total_content_count'): + return obj.total_content_count family_tree = obj.get_descendants(include_self=True) - - # 2. Count all Hadiths that belong to any category in this tree - return Hadis.objects.filter(category__in=family_tree).count() - def get_hadis_count(self,obj): - return len(Hadis.objects.filter(category=obj)) + return Hadis.objects.filter(category__in=family_tree, status=True).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() @@ -274,20 +269,15 @@ class HadisCategorySelectSourceSerializer(serializers.ModelSerializer): return Hadis.objects.filter(category=obj, status=True).exists() def get_children_count(self, obj): - # """Get count of active children categories that have children or hadis""" - # children = obj.get_children().filter(sect=obj.sect) - # return len(children) - """ - Calculates the total number of Hadiths in this category - and all its descendants (sub-categories). - """ - # 1. Get all descendants of this category (including itself) + if hasattr(obj, 'total_content_count'): + return obj.total_content_count family_tree = obj.get_descendants(include_self=True) - - # 2. Count all Hadiths that belong to any category in this tree - return Hadis.objects.filter(category__in=family_tree).count() - def get_hadis_count(self,obj): - return len(Hadis.objects.filter(category=obj)) + return Hadis.objects.filter(category__in=family_tree, status=True).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() class CategorySerializer(serializers.ModelSerializer): sect_id = serializers.IntegerField(source='sect.id', read_only=True) @@ -310,22 +300,18 @@ class CategorySerializer(serializers.ModelSerializer): return obj.parent_id is not None def get_children_count(self, obj): - # """Get count of active children categories that have children or hadis""" - # children = obj.get_children().filter(sect=obj.sect) - # return len(children) - """ - Calculates the total number of Hadiths in this category - and all its descendants (sub-categories). - """ - # 1. Get all descendants of this category (including itself) + if hasattr(obj, 'total_content_count'): + return obj.total_content_count family_tree = obj.get_descendants(include_self=True) - - # 2. Count all Hadiths that belong to any category in this tree - return Hadis.objects.filter(category__in=family_tree).count() - def get_has_hadis(self,obj): - return Hadis.objects.filter(category=obj).exists() - def get_hadis_count(self,obj): - return len(Hadis.objects.filter(category=obj)) + return Hadis.objects.filter(category__in=family_tree, status=True).count() + + def get_has_hadis(self, obj): + return Hadis.objects.filter(category=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() # def get_title(self,obj): # # ✅ Get language from request diff --git a/apps/hadis/views/category.py b/apps/hadis/views/category.py index 93fa06e..37d9220 100644 --- a/apps/hadis/views/category.py +++ b/apps/hadis/views/category.py @@ -266,6 +266,57 @@ class HadisCategoryTreeNormalView(ListAPIView): Q(active_hadis_count__gt=0) | Q(children_total_count__gt=0) | Q(source_type=HadisCategory.SourceType.QURAN) ).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)) + + ordering_param = ( + request.query_params.get('ordering') or + request.query_params.get('sort') or + '' + ).strip().lower() + + if ordering_param in ['content', 'most_content', '-total_content_count', '-content']: + return queryset.order_by('-total_content_count', 'order', 'id') + elif ordering_param in ['alphabetical', 'title', 'slug', 'a-z']: + lang = ( + request.query_params.get('language_code') or + request.query_params.get('lang') or + getattr(request, 'LANGUAGE_CODE', None) + ) + if not lang and hasattr(request, 'headers'): + lang = request.headers.get('Accept-Language') + if lang: + lang = lang.split(',')[0].split(';')[0].split('-')[0].strip().lower() + if not lang: + lang = get_language() or 'en' + + queryset = queryset.annotate( + loc_title=RawSQL(""" + COALESCE( + NULLIF((SELECT COALESCE(elem->>'title', elem->>'text') FROM jsonb_array_elements(CASE WHEN jsonb_typeof(hadis_hadiscategory.title::jsonb) = 'array' THEN hadis_hadiscategory.title::jsonb ELSE '[]'::jsonb END) elem WHERE elem->>'language_code' = %s LIMIT 1), ''), + NULLIF((SELECT COALESCE(elem->>'title', elem->>'text') FROM jsonb_array_elements(CASE WHEN jsonb_typeof(hadis_hadiscategory.title::jsonb) = 'array' THEN hadis_hadiscategory.title::jsonb ELSE '[]'::jsonb END) elem WHERE elem->>'language_code' = 'en' LIMIT 1), ''), + hadis_hadiscategory.slug + ) + """, [lang]) + ) + return queryset.order_by('loc_title', 'slug', 'id') + + return queryset.order_by('order', 'id') + class HadisCategorySelectBySectView(ListAPIView): """ @@ -274,7 +325,6 @@ class HadisCategorySelectBySectView(ListAPIView): """ serializer_class = HadisCategorySelectSerializer pagination_class = StandardResultsSetPagination - @categories_tree_by_sect_swagger def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) @@ -323,7 +373,7 @@ class HadisCategorySelectBySectView(ListAPIView): from django.db.models import Count, Q # Return children of this category that have either active hadiths, children, or are quran - return HadisCategory.objects.filter( + queryset = HadisCategory.objects.filter( parent=parent_category, sect__sect_type=sect_type, sect__is_active=True @@ -332,7 +382,8 @@ class HadisCategorySelectBySectView(ListAPIView): 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('order') + ) + return apply_category_ordering_and_counts(queryset, self.request) class HadisCategorySelectBySectSourceView(ListAPIView): @@ -391,7 +442,7 @@ class HadisCategorySelectBySectSourceView(ListAPIView): from django.db.models import Count, Q # Return children of this category, filtered by source_type and excluding empty leaves - return HadisCategory.objects.filter( + queryset = HadisCategory.objects.filter( parent=parent_category, sect__sect_type=sect_type, sect__is_active=True, @@ -401,7 +452,8 @@ class HadisCategorySelectBySectSourceView(ListAPIView): 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('order') + ) + return apply_category_ordering_and_counts(queryset, self.request) class CategoriesView(ListAPIView): """ @@ -415,14 +467,21 @@ class CategoriesView(ListAPIView): def get_queryset(self): from django.db.models import Count, Q - return HadisCategory.objects.filter( + 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) - ).order_by('order', 'id') + ) + is_root = self.request.query_params.get('is_root', '').lower() + if is_root in ['true', '1']: + queryset = queryset.filter(parent__isnull=True) + elif is_root in ['false', '0']: + queryset = queryset.filter(parent__isnull=False) + + return apply_category_ordering_and_counts(queryset, self.request) @@ -451,7 +510,7 @@ class CategoriesBySectView(ListAPIView): elif is_root in ['false', '0']: queryset = queryset.filter(parent__isnull=False) - return queryset.order_by('order', 'id') + return apply_category_ordering_and_counts(queryset, self.request) @categories_by_sect_swagger def get(self, request, *args, **kwargs): From ee33545d58deed4f338a0befc6df35ea2aa5cddc Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Wed, 9 Sep 2026 12:00:12 +0330 Subject: [PATCH 5/5] feat(hadis): add hadis_ids to pinned and middle collection serializers with prefetch optimization --- apps/hadis/serializers/hadis.py | 34 +++++++++++++++++++++++++++++++-- apps/hadis/views/hadis.py | 14 ++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py index d634347..28ead78 100644 --- a/apps/hadis/serializers/hadis.py +++ b/apps/hadis/serializers/hadis.py @@ -49,11 +49,12 @@ class PinnedHadisCollectionSerializer(serializers.ModelSerializer): title = LocalizedField() summary = LocalizedField() thumbnail = serializers.SerializerMethodField() + hadis_ids = serializers.SerializerMethodField() class Meta: model = HadisCollection # فیلدهایی که برای نمایش در بخش پین شده (مثل اسلایدر یا لیست بالا) نیاز هست - fields = ['id', 'title', 'slug', 'summary', 'thumbnail', 'order', 'created_at'] + fields = ['id', 'title', 'slug', 'summary', 'thumbnail', 'order', 'created_at', 'hadis_ids'] def get_thumbnail(self, obj): if obj.thumbnail: @@ -63,14 +64,29 @@ class PinnedHadisCollectionSerializer(serializers.ModelSerializer): return absolute_https_url(obj.thumbnail.url) return None + def get_hadis_ids(self, obj): + # دریافت شناسه احادیث فعال متعلق به این کالکشن به ترتیب + if hasattr(obj, '_prefetched_objects_cache') and 'hadis_items' in obj._prefetched_objects_cache: + items = [ + item.hadis_id for item in obj.hadis_items.all() + if getattr(getattr(item, 'hadis', None), 'status', True) + ] + return items + return list( + obj.hadis_items.filter(hadis__status=True) + .order_by('order') + .values_list('hadis_id', flat=True) + ) + class HadisCollectionListSerializer(serializers.ModelSerializer): thumbnail = serializers.SerializerMethodField() title = LocalizedField() summary = LocalizedField() + hadis_ids = serializers.SerializerMethodField() class Meta: model = HadisCollection - fields = ['id', 'title', 'summary','slug', 'thumbnail'] + fields = ['id', 'title', 'summary', 'slug', 'thumbnail', 'hadis_ids'] def get_thumbnail(self, obj): if obj.thumbnail: @@ -80,6 +96,20 @@ class HadisCollectionListSerializer(serializers.ModelSerializer): return absolute_https_url(obj.thumbnail.url) return None + def get_hadis_ids(self, obj): + # دریافت شناسه احادیث فعال متعلق به این کالکشن به ترتیب + if hasattr(obj, '_prefetched_objects_cache') and 'hadis_items' in obj._prefetched_objects_cache: + items = [ + item.hadis_id for item in obj.hadis_items.all() + if getattr(getattr(item, 'hadis', None), 'status', True) + ] + return items + return list( + obj.hadis_items.filter(hadis__status=True) + .order_by('order') + .values_list('hadis_id', flat=True) + ) + class HadisSyncSerializer(serializers.ModelSerializer): """Serializer for syncing all hadis data (grouped fields)""" diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py index 290e52d..9510fbb 100644 --- a/apps/hadis/views/hadis.py +++ b/apps/hadis/views/hadis.py @@ -33,10 +33,17 @@ class PinnedHadisCollectionListView(ListAPIView): return self.list(request, *args, **kwargs) def get_queryset(self): + from django.db.models import Prefetch + from apps.hadis.models import HadisInCollection return HadisCollection.objects.filter( status=True, display_position=HadisCollection.DisplayPosition.PINNED, hadis_items__hadis__status=True + ).prefetch_related( + Prefetch( + 'hadis_items', + queryset=HadisInCollection.objects.filter(hadis__status=True).order_by('order') + ) ).distinct().order_by('order', '-created_at') def list(self, request, *args, **kwargs): @@ -100,9 +107,16 @@ class HadisCollectionListView(ListAPIView): return self.list(request, *args, **kwargs) def get_queryset(self): + from django.db.models import Prefetch + from apps.hadis.models import HadisInCollection return HadisCollection.objects.filter( status=True, display_position=HadisCollection.DisplayPosition.MIDDLE + ).prefetch_related( + Prefetch( + 'hadis_items', + queryset=HadisInCollection.objects.filter(hadis__status=True).order_by('order') + ) ).order_by('order', '-created_at')