diff --git a/apps/hadis/admin/reference.py b/apps/hadis/admin/reference.py index 46f683d..1fd2834 100644 --- a/apps/hadis/admin/reference.py +++ b/apps/hadis/admin/reference.py @@ -16,7 +16,8 @@ from ..models import ( BookAuthor, BookAttribute, BookSubjectArea, - BookType + BookType, + BookTag ) # ----------------------------------------------------------------------------- @@ -353,6 +354,43 @@ class BookSubjectAreaAdminForm(forms.ModelForm): }) +class BookTagAdminForm(forms.ModelForm): + """Custom form for BookTag with JSON editor widgets""" + + class Meta: + model = BookTag + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + title_schema = { + "type": "array", + "title": "Titles", + "items": { + "type": "object", + "title": "Title", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + }, + "text": { + "type": "string", + "title": "Title Text" + } + }, + "required": ["language_code", "text"] + } + } + + self.fields['title'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(title_schema), + 'title': 'Titles' + }) + + class BookTypeAdminForm(forms.ModelForm): """Custom form for BookType with JSON editor widgets""" @@ -449,11 +487,11 @@ class BookReferenceAdmin(ModelAdmin): inlines = [BookReferenceImageInline, BookAttributeInline] # Use filter_horizontal for ManyToMany fields to make selection easier - filter_horizontal = ('subject_area',) + filter_horizontal = ('subject_area', 'tags') fieldsets = ( (_('Basic Info'), { - 'fields': ('title', 'description', 'slug', 'language') + 'fields': ('title', 'description', 'slug', 'language', 'tags', 'type') }), (_('Publication Info'), { 'fields': ('number_page',) @@ -572,6 +610,18 @@ class BookAttributeAdmin(ModelAdmin): return '-' +class BookTagAdmin(ModelAdmin): + form = BookTagAdminForm + list_display = ('get_title_display', 'created_at') + + @display(description=_('Title'), ordering='title') + def get_title_display(self, obj): + if obj.title and isinstance(obj.title, list) and len(obj.title) > 0: + first = obj.title[0] + if isinstance(first, dict): + return first.get('text', '-') + return '-' + # class BookSubjectAreaAdmin(ModelAdmin): # """Admin for BookSubjectArea model""" # form = BookSubjectAreaAdminForm @@ -622,5 +672,6 @@ dovoodi_admin_site.register(BookReference, BookReferenceAdmin) dovoodi_admin_site.register(BookAuthor, BookAuthorAdmin) dovoodi_admin_site.register(BookAttribute, BookAttributeAdmin) dovoodi_admin_site.register(BookReferenceImage, BookReferenceImageAdmin) +dovoodi_admin_site.register(BookTag, BookTagAdmin) # dovoodi_admin_site.register(BookSubjectArea, BookSubjectAreaAdmin) # dovoodi_admin_site.register(BookType, BookTypeAdmin) \ No newline at end of file diff --git a/apps/hadis/migrations/0017_booktag_alter_bookreference_subject_area_and_more.py b/apps/hadis/migrations/0017_booktag_alter_bookreference_subject_area_and_more.py new file mode 100644 index 0000000..3cd58b8 --- /dev/null +++ b/apps/hadis/migrations/0017_booktag_alter_bookreference_subject_area_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.12 on 2026-06-20 13:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0016_remove_bookreference_city_of_publication_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='BookTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.JSONField(default=list, verbose_name='Title')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ], + options={ + 'verbose_name': 'Book Tag', + 'verbose_name_plural': 'Book Tags', + }, + ), + migrations.AlterField( + model_name='bookreference', + name='subject_area', + field=models.ManyToManyField(blank=True, to='hadis.booksubjectarea', verbose_name='Subject Area'), + ), + migrations.AlterField( + model_name='bookreference', + name='type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='hadis.booktype', verbose_name='Type'), + ), + migrations.AddField( + model_name='bookreference', + name='tags', + field=models.ManyToManyField(blank=True, related_name='book_references', to='hadis.booktag', verbose_name='Tags'), + ), + ] diff --git a/apps/hadis/models/reference.py b/apps/hadis/models/reference.py index 4ee0861..6eb0e3f 100644 --- a/apps/hadis/models/reference.py +++ b/apps/hadis/models/reference.py @@ -30,6 +30,24 @@ class BookSubjectArea(models.Model): return None +class BookTag(models.Model): + """ + Model for book reference tags + """ + title = models.JSONField(default=list, verbose_name=_('Title')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + verbose_name = _('Book Tag') + verbose_name_plural = _('Book Tags') + + def __str__(self): + try: + return self.title[0]['text'] + except: + return f"Tag {self.id}" + class BookType(models.Model): title = models.JSONField(default = list , verbose_name=_('Title')) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) @@ -66,8 +84,18 @@ class BookReference(models.Model): language = models.JSONField(default = list , verbose_name=_('Language')) number_page = models.PositiveIntegerField(verbose_name=_('number of pages'), blank=True, null=True) slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True) - subject_area = models.ManyToManyField(BookSubjectArea, related_name="book_subjects", verbose_name=_('subject area'), blank=True) - type = models.ForeignKey(BookType, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('type')) + subject_area = models.ManyToManyField( + 'BookSubjectArea', + verbose_name=_('Subject Area'), + blank=True + ) + tags = models.ManyToManyField( + 'BookTag', + verbose_name=_('Tags'), + blank=True, + related_name='book_references' + ) + type = models.ForeignKey('BookType', on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Type')) order = models.IntegerField(default=0, verbose_name=_('Order')) researcher = models.JSONField(default=list, blank=True, verbose_name=_('Researcher')) rate = models.DecimalField( diff --git a/apps/hadis/serializers/reference_v2.py b/apps/hadis/serializers/reference_v2.py index 8ffe9da..4c4b249 100644 --- a/apps/hadis/serializers/reference_v2.py +++ b/apps/hadis/serializers/reference_v2.py @@ -1,15 +1,16 @@ from rest_framework import serializers from rest_framework.pagination import PageNumberPagination -from ..models import BookReference, BookAuthor, BookEdition, BookVolume +from ..models import BookReference, BookAuthor, BookEdition, BookVolume, BookTag from .category import get_localized_text from apps.hadis.serializers import HadisListSerializer, HadisShortSerializer from ..serializers.category import LocalizedField from ..serializers.reference import BookAttributeSerializer, BookReferenceImageSerializer -from rest_framework.pagination import PageNumberPagination -from ..models import BookReference, BookAuthor, BookEdition, BookVolume -from .category import get_localized_text -from apps.hadis.serializers import HadisListSerializer -from ..serializers.category import LocalizedField + +class BookTagV2Serializer(serializers.ModelSerializer): + title = LocalizedField() + class Meta: + model = BookTag + fields = ['id', 'title'] class BookAuthorV2Serializer(serializers.ModelSerializer): name = LocalizedField() @@ -17,6 +18,19 @@ class BookAuthorV2Serializer(serializers.ModelSerializer): model = BookAuthor fields = ['id', 'name', 'slug'] +class BookReferenceV2ListSerializer(serializers.ModelSerializer): + title = LocalizedField() + authors = BookAuthorV2Serializer(many=True, read_only=True) + tags = BookTagV2Serializer(many=True, read_only=True) + number_of_volumes = serializers.SerializerMethodField() + + class Meta: + model = BookReference + fields = ['id', 'slug', 'title', 'authors', 'number_of_volumes', 'tags'] + + def get_number_of_volumes(self, obj): + return obj.volumes.count() + class BookEditionV2Serializer(serializers.ModelSerializer): publisher = LocalizedField() notes = LocalizedField() diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index 2f953c9..c5e4b29 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -4,7 +4,7 @@ from .views.category import HadisCategorySectListView, HadisCategoryTreeView, Ca from .views.hadis import HadisCollectionListView, HadisListView, HadisBasicView, HadisDetailView, HadisSyncView, HadisTransmittersView, HadisCorrectionsView,HadisMainListView, HadisFiltersView, HadisLayersView,PinnedHadisCollectionListView, HadisFiltersSyncAPIView from .views.transmitter import TransmitterView ,TransmitterDetailView, TransmitterSyncView,TransmitterOpinionView, TransmitterOriginalTextView, TransmitterFiltersView from .views.reference import BookDetailView, BookReferencesView, BookReferenceSyncView, BookAttributeView -from .views.reference_v2 import BookReferenceV2DetailView, BookReferenceV2SyncView +from .views.reference_v2 import BookReferenceV2DetailView, BookReferenceV2SyncView, BookReferenceV2ListView from .views.version import ContentReleaseSyncView from .views.info import HadisInfoView from .views_admin import ( @@ -89,6 +89,7 @@ urlpatterns = [ # Reference paths path('v2/references//', BookReferenceV2DetailView.as_view(), name='reference-detail-v2'), + path('v2/references/', BookReferenceV2ListView.as_view(), name='reference-list-v2'), path('references/', BookDetailView.as_view(), name='reference-detail'), path('references/', BookReferencesView.as_view(), name='references'), diff --git a/apps/hadis/views/reference.py b/apps/hadis/views/reference.py index f5f0ba4..58e28d8 100644 --- a/apps/hadis/views/reference.py +++ b/apps/hadis/views/reference.py @@ -99,17 +99,74 @@ class BookReferenceSyncView(ListAPIView): """ Prefetch ALL related data to avoid N+1 queries """ - return ( - BookReference.objects - .select_related() # If any ForeignKey fields - .prefetch_related( - 'authors', - 'attributes', # ← ADDED - 'images', # ← ADDED - 'hadis_references__hadis' - ) - .order_by('id') - ) + qs = BookReference.objects.select_related().prefetch_related( + 'authors', 'attributes', 'images', 'hadis_references__hadis' + ).distinct().order_by('id') + + category = self.request.query_params.get('category') + if category: + qs = qs.filter(subject_area__id=category) + + author = self.request.query_params.get('author') + if author: + qs = qs.filter(authors__id=author) + + sect = self.request.query_params.get('sect') + if sect: + qs = qs.filter(type__id=sect) + + pdf_available = self.request.query_params.get('pdf_available') + if pdf_available == 'true': + qs = qs.filter(documents__isnull=False) + elif pdf_available == 'false': + qs = qs.filter(documents__isnull=True) + + century = self.request.query_params.get('century') + if century: + if century == 'before_islam': + qs = qs.filter(authors__death_year_hijri__lt=0) + elif century.isdigit(): + century_num = int(century) + start_year = (century_num - 1) * 100 + 1 + end_year = century_num * 100 + qs = qs.filter(authors__death_year_hijri__gte=start_year, authors__death_year_hijri__lte=end_year) + + return qs + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + # If pagination is active, paginate it (though it's NoPagination) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + else: + serializer = self.get_serializer(queryset, many=True) + + from ..models import BookSubjectArea, BookType + from ..serializers.category import get_localized_text + + categories_qs = BookSubjectArea.objects.all() + categories = [{"id": c.id, "title": get_localized_text(c.title, request)} for c in categories_qs] + + sects_qs = BookType.objects.all() + sects = [{"id": s.id, "title": get_localized_text(s.title, request)} for s in sects_qs] + + filters_metadata = { + "categories": categories, + "madhab": sects + } + + if page is not None: + response = self.get_paginated_response(serializer.data) + response.data['filters'] = filters_metadata + return response + + return Response({ + "count": queryset.count(), + "filters": filters_metadata, + "results": serializer.data + }) class BookAttributeView(ListCreateAPIView): diff --git a/apps/hadis/views/reference_v2.py b/apps/hadis/views/reference_v2.py index bcd4a8d..82262db 100644 --- a/apps/hadis/views/reference_v2.py +++ b/apps/hadis/views/reference_v2.py @@ -54,16 +54,148 @@ class BookReferenceV2SyncView(generics.ListAPIView): return super().get(request, *args, **kwargs) def get_queryset(self): - return ( - BookReference.objects - .select_related() - .prefetch_related( - 'authors', - 'attributes', - 'images', - 'editions__volumes', - 'volumes', - 'hadis_references__hadis' - ) - .order_by('id') - ) + qs = BookReference.objects.select_related().prefetch_related( + 'authors', 'attributes', 'images', 'editions__volumes', 'volumes', 'hadis_references__hadis' + ).distinct().order_by('id') + + category = self.request.query_params.get('category') + if category: + qs = qs.filter(subject_area__id=category) + + author = self.request.query_params.get('author') + if author: + qs = qs.filter(authors__id=author) + + sect = self.request.query_params.get('sect') + if sect: + qs = qs.filter(type__id=sect) + + pdf_available = self.request.query_params.get('pdf_available') + if pdf_available == 'true': + qs = qs.filter(documents__isnull=False) + elif pdf_available == 'false': + qs = qs.filter(documents__isnull=True) + + century = self.request.query_params.get('century') + if century: + if century == 'before_islam': + qs = qs.filter(authors__death_year_hijri__lt=0) + elif century.isdigit(): + century_num = int(century) + start_year = (century_num - 1) * 100 + 1 + end_year = century_num * 100 + qs = qs.filter(authors__death_year_hijri__gte=start_year, authors__death_year_hijri__lte=end_year) + + return qs + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + serializer = self.get_serializer(queryset, many=True) + + categories_qs = BookSubjectArea.objects.all() + categories = [{"id": c.id, "title": get_localized_text(c.title, request)} for c in categories_qs] + + sects_qs = BookType.objects.all() + sects = [{"id": s.id, "title": get_localized_text(s.title, request)} for s in sects_qs] + + filters_metadata = { + "categories": categories, + "madhab": sects + } + + return Response({ + "count": queryset.count(), + "filters": filters_metadata, + "results": serializer.data + }) + +from rest_framework.response import Response +from ..serializers.reference_v2 import BookReferenceV2ListSerializer +from ..models import BookAuthor, BookSubjectArea, BookType +from utils.pagination import StandardResultsSetPagination +from ..serializers.category import get_localized_text +from drf_yasg import openapi + +class BookReferenceV2ListView(generics.ListAPIView): + """ + API view to list V2 book references with filters and metadata + """ + serializer_class = BookReferenceV2ListSerializer + pagination_class = StandardResultsSetPagination + + @swagger_auto_schema( + operation_summary="List V2 Book References with Filters", + operation_description="Returns a paginated list of book references and available filters (categories, sects).\n" + "Use query parameters to filter the results.", + tags=['Dobodbi - Hadis (V2)'], + manual_parameters=[ + openapi.Parameter('category', openapi.IN_QUERY, description="Filter by Category/Subject Area ID", type=openapi.TYPE_INTEGER), + openapi.Parameter('author', openapi.IN_QUERY, description="Filter by Author ID", type=openapi.TYPE_INTEGER), + openapi.Parameter('sect', openapi.IN_QUERY, description="Filter by Sect/Type ID", type=openapi.TYPE_INTEGER), + openapi.Parameter('pdf_available', openapi.IN_QUERY, description="Filter by PDF availability ('true' or 'false')", type=openapi.TYPE_STRING), + openapi.Parameter('century', openapi.IN_QUERY, description="Filter by Century (e.g. '1', '2', 'before_islam')", type=openapi.TYPE_STRING), + ] + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + qs = BookReference.objects.prefetch_related('authors', 'tags', 'volumes').distinct().order_by('-created_at') + + category = self.request.query_params.get('category') + if category: + qs = qs.filter(subject_area__id=category) + + author = self.request.query_params.get('author') + if author: + qs = qs.filter(authors__id=author) + + sect = self.request.query_params.get('sect') + if sect: + qs = qs.filter(type__id=sect) + + pdf_available = self.request.query_params.get('pdf_available') + if pdf_available == 'true': + qs = qs.filter(documents__isnull=False) + elif pdf_available == 'false': + qs = qs.filter(documents__isnull=True) + + century = self.request.query_params.get('century') + if century: + if century == 'before_islam': + qs = qs.filter(authors__death_year_hijri__lt=0) + elif century.isdigit(): + century_num = int(century) + start_year = (century_num - 1) * 100 + 1 + end_year = century_num * 100 + qs = qs.filter(authors__death_year_hijri__gte=start_year, authors__death_year_hijri__lte=end_year) + + return qs + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + page = self.paginate_queryset(queryset) + + serializer = self.get_serializer(page if page is not None else queryset, many=True) + + categories_qs = BookSubjectArea.objects.all() + categories = [{"id": c.id, "title": get_localized_text(c.title, request)} for c in categories_qs] + + sects_qs = BookType.objects.all() + sects = [{"id": s.id, "title": get_localized_text(s.title, request)} for s in sects_qs] + + filters_metadata = { + "categories": categories, + "madhab": sects + } + + if page is not None: + response = self.get_paginated_response(serializer.data) + # Add filters directly into the response dict + response.data['filters'] = filters_metadata + return response + + return Response({ + "filters": filters_metadata, + "results": serializer.data + })