diff --git a/apps/article/views.py b/apps/article/views.py index 92abe4a..4e7090f 100755 --- a/apps/article/views.py +++ b/apps/article/views.py @@ -1,13 +1,14 @@ from django.db.models import Q from rest_framework import generics, status from rest_framework.response import Response +from rest_framework.decorators import action from rest_framework.authentication import TokenAuthentication from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from apps.library.pagination import NoPagination -from rest_framework.permissions import IsAuthenticated from utils.pagination import StandardResultsSetPagination @@ -284,3 +285,21 @@ class DovodiArticleCollectionViewSet(ModelViewSet): if search_query: queryset = queryset.filter(Q(title__icontains=search_query) | Q(slug__icontains=search_query)) return queryset + + @action(detail=True, methods=["post"], url_path="assign-items") + def assign_items(self, request, pk=None): + collection = self.get_object() + add_ids = request.data.get("add_ids", []) or request.data.get("item_ids", []) + remove_ids = request.data.get("remove_ids", []) + + if add_ids: + new_items = [ + ArticleInCollection(article_id=aid, collection_id=collection.id) + for aid in add_ids + ] + ArticleInCollection.objects.bulk_create(new_items, ignore_conflicts=True) + + if remove_ids: + ArticleInCollection.objects.filter(collection_id=collection.id, article_id__in=remove_ids).delete() + + return Response({"status": "success", "attached_count": collection.collection_articles.count()}) diff --git a/apps/article/views_admin.py b/apps/article/views_admin.py index 554b379..ddd33fe 100644 --- a/apps/article/views_admin.py +++ b/apps/article/views_admin.py @@ -40,7 +40,7 @@ class AdminArticleViewSet(ModelViewSet): return AdminArticleDetailSerializer def get_queryset(self): - queryset = Article.objects.all().prefetch_related("categories") + queryset = Article.objects.all().prefetch_related("categories", "collections") search_query = self.request.query_params.get("search") if search_query: @@ -55,6 +55,10 @@ class AdminArticleViewSet(ModelViewSet): if category_id and category_id != "all": queryset = queryset.filter(categories__id=category_id) + collection_id = self.request.query_params.get("collection") or self.request.query_params.get("collection_id") + if collection_id and collection_id != "all": + queryset = queryset.filter(collections__id=collection_id) + status_filter = self.request.query_params.get("status") if status_filter in {"true", "false"}: queryset = queryset.filter(status=status_filter == "true") diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index 1b35165..d792a68 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/apps/hadis/serializers/serializers_admin.py @@ -96,8 +96,16 @@ class AdminHadisTagSerializer(serializers.ModelSerializer): fields = ["id", "title", "status"] +class AdminHadisCategoryListSerializer(serializers.ModelSerializer): + title = serializers.JSONField() + + class Meta: + model = HadisCategory + fields = ["id", "title", "slug", "source_type", "order"] + + class AdminHadisListSerializer(serializers.ModelSerializer): - category_detail = AdminHadisCategorySerializer(source="category", read_only=True) + category_detail = AdminHadisCategoryListSerializer(source="category", read_only=True) hadis_status_detail = AdminHadisStatusSerializer(source="hadis_status", read_only=True) collections_detail = serializers.SerializerMethodField() @@ -123,8 +131,10 @@ class AdminHadisListSerializer(serializers.ModelSerializer): read_only_fields = ["id", "slug", "created_at", "updated_at"] def get_collections_detail(self, obj): - collections = [item.collection for item in obj.collection_items.all()] - return AdminHadisCollectionSerializer(collections, many=True).data + return [ + {"id": item.collection_id, "title": item.collection.title, "slug": item.collection.slug} + for item in obj.collection_items.all() + ] class HadisCollectionsField(serializers.PrimaryKeyRelatedField): @@ -263,6 +273,14 @@ class AdminHadisDetailSerializer(serializers.ModelSerializer): refs = list(instance.references.all()) ref = refs[0] if refs else None ret["book_reference"] = ref.book_reference_id if ref else None + if ref and ref.book_reference: + ret["book_reference_detail"] = { + "id": ref.book_reference.id, + "title": ref.book_reference.title, + "slug": ref.book_reference.slug, + } + else: + ret["book_reference_detail"] = None return ret def get_bookmark_count(self, obj): @@ -966,6 +984,8 @@ class AdminBookReferenceDocumentSerializer(serializers.ModelSerializer): class AdminBookReferenceListSerializer(serializers.ModelSerializer): + author_id = serializers.IntegerField(source="author.id", read_only=True, allow_null=True) + author_name = serializers.JSONField(source="author.name", read_only=True, allow_null=True) volume = serializers.SerializerMethodField() publisher = serializers.SerializerMethodField() year_of_publication = serializers.SerializerMethodField() @@ -977,6 +997,8 @@ class AdminBookReferenceListSerializer(serializers.ModelSerializer): "title", "slug", "has_editions", + "author_id", + "author_name", "volume", "publisher", "year_of_publication", diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index 9a8f865..77c5fd6 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -7,10 +7,10 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from apps.account.permissions import IsSuperAdmin -from utils.pagination import StandardResultsSetPagination +from utils.pagination import StandardResultsSetPagination, OptionalPagination from .models import ( - Hadis, HadisCategory, HadisCollection, HadisCorrection, HadisInterpretation, HadisStatus, HadisTag, HadisSect, HadisTransmitter, + Hadis, HadisCategory, HadisCollection, HadisInCollection, HadisCorrection, HadisInterpretation, HadisStatus, HadisTag, HadisSect, HadisTransmitter, BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAttribute, BookSubjectArea, BookType, BookEdition, BookVolume, Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, TransmitterOpinion, TransmitterOriginalText ) @@ -51,7 +51,7 @@ class AdminHadisCategoryViewSet(ReadOnlyModelViewSet): serializer_class = AdminHadisCategorySerializer permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] - pagination_class = None + pagination_class = OptionalPagination def get_queryset(self): hadis_subquery = Hadis.objects.filter(category=OuterRef("pk")) @@ -63,6 +63,11 @@ class AdminHadisCategoryViewSet(ReadOnlyModelViewSet): source_type = self.request.query_params.get("source_type") if source_type: queryset = queryset.filter(source_type=source_type) + search = self.request.query_params.get("search") + if search: + queryset = queryset.filter( + Q(title__icontains=search) | Q(slug__icontains=search) + ) return queryset.order_by("order", "id") @@ -117,7 +122,7 @@ class AdminHadisStatusViewSet(ModelViewSet): serializer_class = AdminHadisStatusSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] - pagination_class = None + pagination_class = OptionalPagination parser_classes = (JSONParser, MultiPartParser, FormParser) def get_queryset(self): @@ -168,6 +173,24 @@ class AdminHadisCollectionViewSet(ModelViewSet): queryset = queryset.filter(status=status_filter == "true") return queryset.order_by("order", "id") + @action(detail=True, methods=["post"], url_path="assign-items") + def assign_items(self, request, pk=None): + collection = self.get_object() + add_ids = request.data.get("add_ids", []) or request.data.get("item_ids", []) + remove_ids = request.data.get("remove_ids", []) + + if add_ids: + new_items = [ + HadisInCollection(hadis_id=hid, collection_id=collection.id) + for hid in add_ids + ] + HadisInCollection.objects.bulk_create(new_items, ignore_conflicts=True) + + if remove_ids: + HadisInCollection.objects.filter(collection_id=collection.id, hadis_id__in=remove_ids).delete() + + return Response({"status": "success", "attached_count": collection.hadis_items.count()}) + class AdminTransmitterReliabilityViewSet(ReadOnlyModelViewSet): serializer_class = AdminTransmitterReliabilitySerializer @@ -295,7 +318,7 @@ class AdminHadisViewSet(ModelViewSet): def get_queryset(self): queryset = ( Hadis.objects.all() - .select_related("category__sect", "hadis_status") + .select_related("category", "hadis_status") .prefetch_related("collection_items__collection") ) @@ -316,6 +339,10 @@ class AdminHadisViewSet(ModelViewSet): if category_id and category_id != "all": queryset = queryset.filter(category_id=category_id) + collection_id = self.request.query_params.get("collection") or self.request.query_params.get("collection_id") + if collection_id and collection_id != "all": + queryset = queryset.filter(collection_items__collection_id=collection_id) + source_type = self.request.query_params.get("source_type") if source_type: queryset = queryset.filter(category__source_type=source_type) @@ -324,7 +351,7 @@ class AdminHadisViewSet(ModelViewSet): if status_filter in {"true", "false"}: queryset = queryset.filter(status=status_filter == "true") - return queryset.distinct().order_by("-id") + return queryset.order_by("-id") class AdminHadisCorrectionViewSet(ModelViewSet): @@ -482,6 +509,7 @@ class AdminBookAuthorViewSet(ModelViewSet): permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] pagination_class = None + parser_classes = (MultiPartParser, FormParser, JSONParser) def get_queryset(self): queryset = BookAuthor.objects.all() @@ -490,6 +518,20 @@ class AdminBookAuthorViewSet(ModelViewSet): queryset = queryset.filter(name__icontains=search_query) return queryset.order_by("id") + @action(detail=True, methods=["post"], url_path="assign-references") + def assign_references(self, request, pk=None): + author = self.get_object() + add_ids = request.data.get("add_ids", []) or request.data.get("book_ids", []) or request.data.get("item_ids", []) + remove_ids = request.data.get("remove_ids", []) + + if add_ids: + BookReference.objects.filter(id__in=add_ids).update(author=author) + + if remove_ids: + BookReference.objects.filter(id__in=remove_ids, author=author).update(author=None) + + return Response({"status": "success", "references_count": author.book_references.count()}) + class AdminBookAttributeViewSet(ModelViewSet): serializer_class = AdminBookAttributeSerializer diff --git a/apps/library/serializers_admin.py b/apps/library/serializers_admin.py index 5337135..face95f 100644 --- a/apps/library/serializers_admin.py +++ b/apps/library/serializers_admin.py @@ -101,10 +101,23 @@ class AdminAuthorSerializer(serializers.ModelSerializer): return super().update(instance, validated_data) +class AdminLibraryCategoryBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = ["id", "title", "slug", "status"] + + +class AdminLibraryCollectionBriefSerializer(serializers.ModelSerializer): + class Meta: + model = BookCollection + fields = ["id", "title", "slug", "status"] + + class AdminBookListSerializer(serializers.ModelSerializer): thumbnail = AbsoluteImageField(required=False, allow_null=True) book_file = AbsoluteFileField(required=False, allow_null=True) - categories_detail = AdminLibraryCategorySerializer(source="categories", many=True, read_only=True) + categories_detail = AdminLibraryCategoryBriefSerializer(source="categories", many=True, read_only=True) + collections_detail = AdminLibraryCollectionBriefSerializer(source="collections", many=True, read_only=True) author = serializers.CharField(source='author.full_name', read_only=True) author_id = serializers.IntegerField(read_only=True) @@ -126,6 +139,7 @@ class AdminBookListSerializer(serializers.ModelSerializer): "created_at", "updated_at", "categories_detail", + "collections_detail", "author_id", "book_file", ] diff --git a/apps/library/urls.py b/apps/library/urls.py index 3246121..389f05e 100644 --- a/apps/library/urls.py +++ b/apps/library/urls.py @@ -30,12 +30,16 @@ admin_router.register(r'dovodi-categories', DovodiAdminLibraryCategoryViewSet, b admin_router.register(r'collections', AdminLibraryCollectionViewSet, basename='admin-library-collections') admin_router.register(r'dovodi-collections', DovodiAdminLibraryCollectionViewSet, basename='admin-library-dovodi-collections') +dovodi_router = SimpleRouter() +dovodi_router.register(r'collections', DovodiAdminLibraryCollectionViewSet, basename='dovodi-library-collections') + # Hide admin viewsets from swagger for prefix, viewset, basename in admin_router.registry: viewset.swagger_schema = None urlpatterns = [ path('admin/', include(admin_router.urls)), + path('dovodi/', include(dovodi_router.urls)), path('categories/', CategoryListView.as_view(), name='category-list'), path('pinned-collections/', PinnedBookCollectionListView.as_view(), name='pinned-collection-list'), path('collections/', MiddleBookCollectionListView.as_view(), name='collection-list'), diff --git a/apps/library/views_admin.py b/apps/library/views_admin.py index 54c8d8b..01ccf1b 100644 --- a/apps/library/views_admin.py +++ b/apps/library/views_admin.py @@ -1,7 +1,9 @@ from django.db.models import Count, Q from rest_framework.authentication import TokenAuthentication +from rest_framework.decorators import action from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from apps.account.permissions import IsSuperAdmin @@ -50,15 +52,30 @@ class DovodiAdminLibraryCategoryViewSet(ModelViewSet): return queryset.order_by("title", "id") -class AdminLibraryCollectionViewSet(ReadOnlyModelViewSet): +class AdminLibraryCollectionViewSet(ModelViewSet): serializer_class = AdminLibraryCollectionSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] pagination_class = None + parser_classes = (MultiPartParser, FormParser, JSONParser) def get_queryset(self): return BookCollection.objects.all().order_by("display_position", "order", "title") + @action(detail=True, methods=["post"], url_path="assign-items") + def assign_items(self, request, pk=None): + collection = self.get_object() + add_ids = request.data.get("add_ids", []) or request.data.get("item_ids", []) + remove_ids = request.data.get("remove_ids", []) + + if add_ids: + collection.related_collections.add(*add_ids) + + if remove_ids: + collection.related_collections.remove(*remove_ids) + + return Response({"status": "success", "attached_count": collection.related_collections.count()}) + class DovodiAdminLibraryCollectionViewSet(ModelViewSet): serializer_class = AdminLibraryCollectionSerializer @@ -84,6 +101,20 @@ class DovodiAdminLibraryCollectionViewSet(ModelViewSet): return queryset.order_by("display_position", "order", "title", "id") + @action(detail=True, methods=["post"], url_path="assign-items") + def assign_items(self, request, pk=None): + collection = self.get_object() + add_ids = request.data.get("add_ids", []) or request.data.get("item_ids", []) + remove_ids = request.data.get("remove_ids", []) + + if add_ids: + collection.related_collections.add(*add_ids) + + if remove_ids: + collection.related_collections.remove(*remove_ids) + + return Response({"status": "success", "attached_count": collection.related_collections.count()}) + class AdminAuthorViewSet(ModelViewSet): serializer_class = AdminAuthorSerializer @@ -142,7 +173,7 @@ class AdminBookViewSet(ModelViewSet): if category_id and category_id != "all": queryset = queryset.filter(categories__id=category_id) - collection_id = self.request.query_params.get("collection") + collection_id = self.request.query_params.get("collection") or self.request.query_params.get("collection_id") if collection_id and collection_id != "all": queryset = queryset.filter(collections__id=collection_id) diff --git a/apps/podcast/serializers_admin.py b/apps/podcast/serializers_admin.py index 7dd7104..7128c2b 100644 --- a/apps/podcast/serializers_admin.py +++ b/apps/podcast/serializers_admin.py @@ -208,7 +208,9 @@ class AdminPodcastPlaylistListSerializer(serializers.ModelSerializer): read_only_fields = ["id", "view_count", "total_time", "created_at", "updated_at", "items_count"] def get_items_count(self, obj): - return obj.playlist_items.count() + if hasattr(obj, "annotated_items_count"): + return obj.annotated_items_count + return len(obj.playlist_items.all()) class AdminPodcastPlaylistDetailSerializer(serializers.ModelSerializer): diff --git a/apps/podcast/views.py b/apps/podcast/views.py index c6be57b..986b4bb 100644 --- a/apps/podcast/views.py +++ b/apps/podcast/views.py @@ -399,6 +399,24 @@ class DovodiPodcastCollectionViewSet(ModelViewSet): queryset = queryset.filter(Q(title__icontains=search_query) | Q(slug__icontains=search_query)) return queryset + @action(detail=True, methods=["post"], url_path="assign-items") + def assign_items(self, request, pk=None): + collection = self.get_object() + add_ids = request.data.get("add_ids", []) or request.data.get("item_ids", []) + remove_ids = request.data.get("remove_ids", []) + + if add_ids: + new_items = [ + PodcastPlaylistInCollection(playlist_id=pid, collection_id=collection.id) + for pid in add_ids + ] + PodcastPlaylistInCollection.objects.bulk_create(new_items, ignore_conflicts=True) + + if remove_ids: + PodcastPlaylistInCollection.objects.filter(collection_id=collection.id, playlist_id__in=remove_ids).delete() + + return Response({"status": "success", "attached_count": collection.collection_playlists.count()}) + class DovodiPodcastItemViewSet(ModelViewSet): serializer_class = DovodiPodcastItemSerializer diff --git a/apps/podcast/views_admin.py b/apps/podcast/views_admin.py index cdc900c..f9088a0 100644 --- a/apps/podcast/views_admin.py +++ b/apps/podcast/views_admin.py @@ -11,7 +11,7 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from apps.account.permissions import IsSuperAdmin from utils.pagination import StandardResultsSetPagination -from .models import PlaylistItem, Podcast, PodcastCollection, PodcastCategory, PodcastPlaylist +from .models import PlaylistItem, Podcast, PodcastCollection, PodcastCategory, PodcastPlaylist, PodcastPlaylistInCollection from .serializers_admin import ( AdminPodcastCategorySerializer, AdminPodcastCollectionSerializer, @@ -33,15 +33,34 @@ class AdminPodcastCategoryViewSet(ReadOnlyModelViewSet): return PodcastCategory.objects.all().order_by("title") -class AdminPodcastCollectionViewSet(ReadOnlyModelViewSet): +class AdminPodcastCollectionViewSet(ModelViewSet): serializer_class = AdminPodcastCollectionSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] pagination_class = None + parser_classes = (MultiPartParser, FormParser, JSONParser) def get_queryset(self): return PodcastCollection.objects.all().order_by("display_position", "order", "title") + @action(detail=True, methods=["post"], url_path="assign-items") + def assign_items(self, request, pk=None): + collection = self.get_object() + add_ids = request.data.get("add_ids", []) or request.data.get("item_ids", []) + remove_ids = request.data.get("remove_ids", []) + + if add_ids: + new_items = [ + PodcastPlaylistInCollection(playlist_id=pid, collection_id=collection.id) + for pid in add_ids + ] + PodcastPlaylistInCollection.objects.bulk_create(new_items, ignore_conflicts=True) + + if remove_ids: + PodcastPlaylistInCollection.objects.filter(collection_id=collection.id, playlist_id__in=remove_ids).delete() + + return Response({"status": "success", "attached_count": collection.collection_playlists.count()}) + class AdminPodcastViewSet(ModelViewSet): permission_classes = [IsAuthenticated, IsSuperAdmin] @@ -115,7 +134,7 @@ class AdminPodcastPlaylistViewSet(ModelViewSet): if category_id and category_id != "all": queryset = queryset.filter(categories__id=category_id) - collection_id = self.request.query_params.get("collection") + collection_id = self.request.query_params.get("collection") or self.request.query_params.get("collection_id") if collection_id and collection_id != "all": queryset = queryset.filter(collections__id=collection_id) diff --git a/apps/video/serializers_admin.py b/apps/video/serializers_admin.py index fd73d08..a0a9239 100644 --- a/apps/video/serializers_admin.py +++ b/apps/video/serializers_admin.py @@ -209,7 +209,9 @@ class AdminVideoPlaylistListSerializer(serializers.ModelSerializer): read_only_fields = ["id", "view_count", "total_time", "created_at", "updated_at", "items_count"] def get_items_count(self, obj): - return obj.playlist_items.count() + if hasattr(obj, "annotated_items_count"): + return obj.annotated_items_count + return len(obj.playlist_items.all()) class AdminVideoPlaylistDetailSerializer(serializers.ModelSerializer): diff --git a/apps/video/views.py b/apps/video/views.py index 5d3aff9..dc14f69 100644 --- a/apps/video/views.py +++ b/apps/video/views.py @@ -482,6 +482,24 @@ class DovodiVideoCollectionViewSet(ModelViewSet): queryset = queryset.filter(Q(title__icontains=search_query) | Q(slug__icontains=search_query)) return queryset + @action(detail=True, methods=["post"], url_path="assign-items") + def assign_items(self, request, pk=None): + collection = self.get_object() + add_ids = request.data.get("add_ids", []) or request.data.get("item_ids", []) + remove_ids = request.data.get("remove_ids", []) + + if add_ids: + new_items = [ + VideoPlaylistInCollection(playlist_id=pid, collection_id=collection.id) + for pid in add_ids + ] + VideoPlaylistInCollection.objects.bulk_create(new_items, ignore_conflicts=True) + + if remove_ids: + VideoPlaylistInCollection.objects.filter(collection_id=collection.id, playlist_id__in=remove_ids).delete() + + return Response({"status": "success", "attached_count": collection.collection_playlists.count()}) + class DovodiVideoItemViewSet(ModelViewSet): serializer_class = DovodiVideoItemSerializer diff --git a/apps/video/views_admin.py b/apps/video/views_admin.py index 9f46e35..7f843ec 100644 --- a/apps/video/views_admin.py +++ b/apps/video/views_admin.py @@ -11,7 +11,7 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from apps.account.permissions import IsSuperAdmin from utils.pagination import StandardResultsSetPagination -from .models import PlaylistItem, Video, VideoCategory, VideoCollection, VideoPlaylist +from .models import PlaylistItem, Video, VideoCategory, VideoCollection, VideoPlaylist, VideoPlaylistInCollection from .serializers_admin import ( AdminVideoCategorySerializer, AdminVideoCollectionSerializer, @@ -33,15 +33,34 @@ class AdminVideoCategoryViewSet(ReadOnlyModelViewSet): return VideoCategory.objects.all().order_by("title") -class AdminVideoCollectionViewSet(ReadOnlyModelViewSet): +class AdminVideoCollectionViewSet(ModelViewSet): serializer_class = AdminVideoCollectionSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] pagination_class = None + parser_classes = (MultiPartParser, FormParser, JSONParser) def get_queryset(self): return VideoCollection.objects.all().order_by("display_position", "order", "title") + @action(detail=True, methods=["post"], url_path="assign-items") + def assign_items(self, request, pk=None): + collection = self.get_object() + add_ids = request.data.get("add_ids", []) or request.data.get("item_ids", []) + remove_ids = request.data.get("remove_ids", []) + + if add_ids: + new_items = [ + VideoPlaylistInCollection(playlist_id=pid, collection_id=collection.id) + for pid in add_ids + ] + VideoPlaylistInCollection.objects.bulk_create(new_items, ignore_conflicts=True) + + if remove_ids: + VideoPlaylistInCollection.objects.filter(collection_id=collection.id, playlist_id__in=remove_ids).delete() + + return Response({"status": "success", "attached_count": collection.collection_playlists.count()}) + class AdminVideoViewSet(ModelViewSet): permission_classes = [IsAuthenticated, IsSuperAdmin] @@ -112,7 +131,7 @@ class AdminVideoPlaylistViewSet(ModelViewSet): if category_id and category_id != "all": queryset = queryset.filter(categories__id=category_id) - collection_id = self.request.query_params.get("collection") + collection_id = self.request.query_params.get("collection") or self.request.query_params.get("collection_id") if collection_id and collection_id != "all": queryset = queryset.filter(collections__id=collection_id) diff --git a/scripts/generate_wise_sayings_hadiths.py b/scripts/generate_wise_sayings_hadiths.py new file mode 100644 index 0000000..ff324d8 --- /dev/null +++ b/scripts/generate_wise_sayings_hadiths.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +""" +Script to generate 50 realistic hadiths for the category 'wise-sayings'. + +Features: +- Connects to the 'wise-sayings' category (HadisCategory) +- Generates rich multilingual content (RU, EN, FA, AR) +- Attaches transmitters and book references +- Assigns readable unique slugs +- Supports --dry-run flag +""" + +import os +import sys +import random +import time +import argparse +from pathlib import Path +from django.utils.text import slugify + +# Force UTF-8 stdout for Windows consoles +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BASE_DIR)) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.base') + +import django +django.setup() + +from django.db import transaction +from apps.hadis.models import ( + HadisCategory, Hadis, HadisStatus, HadisTag, + Transmitters, HadisTransmitter, HadisReference +) +from apps.hadis.models.reference import BookReference + +# 50 Wisdom-themed Hadith templates +WISDOM_TEMPLATES = [ + { + "narrator_ru": "Повелитель верующих Али (мир ему)", + "narrator_en": "Amir al-Mu'minin Ali (peace be upon him)", + "narrator_fa": "امیرالمؤمنین علی (ع)", + "title_ru": "Ценность человека и его знания", + "title_en": "The Worth of a Person and Their Knowledge", + "title_fa": "ارزش هر انسان و دانش او", + "text": "قِيمَةُ كُلِّ امْرِئٍ مَا يُحْسِنُهُ.", + "trans_ru": "Ценность каждого человека заключается в том, что он делает наилучшим образом.", + "trans_en": "The value of every person lies in that which they excel at.", + "trans_fa": "ارزش هر انسانی به اندازه مهارتی است که آن را نیکو انجام می‌دهد.", + "exp_ru": "Этот афоризм подчеркивает значимость практических навыков, знаний и добродетелей.", + "exp_en": "This profound saying emphasizes that true human dignity comes from virtue and knowledge." + }, + { + "narrator_ru": "Посланник Аллаха (да благословит Аллах его и род его)", + "narrator_en": "The Messenger of Allah (peace be upon him and his family)", + "narrator_fa": "پیامبر اکرم (ص)", + "title_ru": "Мудрость как потерянное достояние верующего", + "title_en": "Wisdom as the Lost Treasure of the Believer", + "title_fa": "حکمت؛ گمشده مومن", + "text": "الْحِكْمَةُ ضَالَّةُ الْمُؤْمِنِ، فَخُذِ الْحِكْمَةَ وَلَوْ مِنْ أَهْلِ النِّفَاقِ.", + "trans_ru": "Мудрость — потерянное достояние верующего; берите же мудрость, даже если она исходит от лицемера.", + "trans_en": "Wisdom is the lost property of the believer; seek wisdom wherever it may be found.", + "trans_fa": "حکمت گمشده مؤمن است، پس حکمت را فرا گیرید حتی اگر از اهل نفاق باشد.", + "exp_ru": "Истина ценна сама по себе вне зависимости от того, кто ее произносит.", + "exp_en": "Truth and wisdom possess intrinsic value regardless of their source." + }, + { + "narrator_ru": "Имам Джафар ас-Садик (мир ему)", + "narrator_en": "Imam Ja'far al-Sadiq (peace be upon him)", + "narrator_fa": "امام جعفر صادق (ع)", + "title_ru": "Разум и опора человека", + "title_en": "Intellect as the Pillar of Humanity", + "title_fa": "عقل؛ ستون انسانیت", + "text": "دِعَامَةُ الإِنْسَانِ الْعَقْلُ، وَالْعَقْلُ مِنْهُ الْفِطْنَةُ وَالْفَهْمُ وَالْحِفْظُ وَالْعِلْمُ.", + "trans_ru": "Опора человека — это разум, а от разума происходят сообразительность, понимание, память и знание.", + "trans_en": "The pillar of a human being is the intellect, from which stem perception, understanding, memory, and knowledge.", + "trans_fa": "ستون و قوام انسان، عقل است؛ و از عقل زیرکی، فهم، حفظ و علم سرچشمه می‌گیرد.", + "exp_ru": "Разум является фундаментом для нравственного и интеллектуального развития личности.", + "exp_en": "Intellect serves as the essential bedrock for spiritual and practical enlightenment." + }, + { + "narrator_ru": "Повелитель верующих Али (мир ему)", + "narrator_en": "Amir al-Mu'minin Ali (peace be upon him)", + "narrator_fa": "امیرالمؤمنین علی (ع)", + "title_ru": "Молчание и спасение языка", + "title_en": "Silence and the Guarding of Speech", + "title_fa": "سکوت و پاسداری از زبان", + "text": "إِذَا تَمَّ الْعَقْلُ نَقَصَ الْكَلَامُ.", + "trans_ru": "Когда разум достигает совершенства, речь становится немногословной.", + "trans_en": "When the intellect reaches perfection, speech becomes concise.", + "trans_fa": "هنگامی که خرد به کمال رسد، سخن گفتن کاهش می‌یابد.", + "exp_ru": "Зрелый разум побуждает человека взвешивать каждое слово перед произнесением.", + "exp_en": "A mature mind naturally values purposeful reflection over excessive talking." + }, + { + "narrator_ru": "Посланник Аллаха (да благословит Аллах его и род его)", + "narrator_en": "The Messenger of Allah (peace be upon him and his family)", + "narrator_fa": "پیامبر اکرم (ص)", + "title_ru": "Доброе слово как милостыня", + "title_en": "A Good Word is Charity", + "title_fa": "کلام نیکو صدقه است", + "text": "الْكَلِمَةُ الطَّيِّبَةُ صَدَقَةٌ.", + "trans_ru": "Доброе, благое слово — это милостыня (садака).", + "trans_en": "A kind and pleasant word is an act of charity.", + "trans_fa": "سخن پاک و نیکو صدقه است.", + "exp_ru": "Даже доброе отношение и утешительное слово приравниваются к благодеяниям.", + "exp_en": "Kind words uplift hearts and constitute noble spiritual contributions." + }, + { + "narrator_ru": "Имам Мухаммад аль-Бакир (мир ему)", + "narrator_en": "Imam Muhammad al-Baqir (peace be upon him)", + "narrator_fa": "امام محمد باقر (ع)", + "title_ru": "Искренность в делах и намерениях", + "title_en": "Sincerity in Actions and Intentions", + "title_fa": "اخلاص در عمل و نیت", + "text": "الإِبْقَاءُ عَلَى الْعَمَلِ حَتَّى يَخْلُصَ أَشَدُّ مِنَ الْعَمَلِ.", + "trans_ru": "Сохранить дело в чистоте и искренности труднее, чем совершить само дело.", + "trans_en": "Preserving the purity of an action is more demanding than the act itself.", + "trans_fa": "پایداری بر اخلاص در عمل سخت‌تر از خود عمل است.", + "exp_ru": "Искренность требует непрерывной внутренней работы и защиты от тщеславия.", + "exp_en": "Sustained purity of intention safeguards noble actions from vanity." + }, + { + "narrator_ru": "Повелитель верующих Али (мир ему)", + "narrator_en": "Amir al-Mu'minin Ali (peace be upon him)", + "narrator_fa": "امیرالمؤمنین علی (ع)", + "title_ru": "Терпение как вершина веры", + "title_en": "Patience as the Summit of Faith", + "title_fa": "صبر؛ راس ایمان", + "text": "الصَّبْرُ مِنَ الإِيمَانِ كَالرَّأْسِ مِنَ الْجَسَدِ.", + "trans_ru": "Терпение в вере подобно голове на теле: нет веры у того, у кого нет терпения.", + "trans_en": "Patience is to faith what the head is to the body.", + "trans_fa": "جایگاه صبر در ایمان همانند جایگاه سر نسبت به بدن است.", + "exp_ru": "Без терпения невозможно удержать духовные и нравственные ориентиры в жизни.", + "exp_en": "Patience stabilizes human resolve in the face of life's unpredictable trials." + }, + { + "narrator_ru": "Имам Али ар-Рида (мир ему)", + "narrator_en": "Imam Ali al-Rida (peace be upon him)", + "narrator_fa": "امام علی بن موسی الرضا (ع)", + "title_ru": "Дружба с разумом", + "title_en": "Friendship with Intellect", + "title_fa": "دوستی با خرد", + "text": "صَدِيقُ كُلِّ امْرِئٍ عَقْلُهُ، وَعَدُوُّهُ جَهْلُهُ.", + "trans_ru": "Истинный друг каждого человека — его разум, а его заклятый враг — его невежество.", + "trans_en": "Every person's true friend is their intellect, and their greatest enemy is ignorance.", + "trans_fa": "دوست هر فردی خرد اوست و دشمن او نادانی‌اش.", + "exp_ru": "Следование здравому смыслу оберегает человека от пагубных ошибок.", + "exp_en": "Intellectual clarity shields individuals from self-inflicted pitfalls." + }, + { + "narrator_ru": "Посланник Аллаха (да благословит Аллах его и род его)", + "narrator_en": "The Messenger of Allah (peace be upon him and his family)", + "narrator_fa": "پیامبر اکرم (ص)", + "title_ru": "Лучшие из людей", + "title_en": "The Best of Mankind", + "title_fa": "بهترین مردم", + "text": "خَيْرُ النَّاسِ أَنْفَعُهُمْ لِلنَّاسِ.", + "trans_ru": "Лучший из людей — тот, кто приносит наибольшую пользу людям.", + "trans_en": "The best among people is the one who brings the most benefit to others.", + "trans_fa": "بهترین مردم سودمندترین آنان برای دیگران است.", + "exp_ru": "Служение обществу и помощь ближним — высшая мера добродетели.", + "exp_en": "Selfless service to humanity is the cornerstone of spiritual distinction." + }, + { + "narrator_ru": "Повелитель верующих Али (мир ему)", + "narrator_en": "Amir al-Mu'minin Ali (peace be upon him)", + "narrator_fa": "امیرالمؤمنین علی (ع)", + "title_ru": "Очищение сердца от зависти", + "title_en": "Purifying the Heart from Envy", + "title_fa": "پاکی دل از رشک و حسد", + "text": "الْحَسَدُ يَأْكُلُ الإِيمَانَ كَمَا تَأْكُلُ النَّارُ الْحَطَبَ.", + "trans_ru": "Зависть пожирает веру так же стремительно, как огонь пожирает сухие дрова.", + "trans_en": "Envy consumes faith just as fire consumes dry wood.", + "trans_fa": "حسادت ایمان را فرو می‌خورد، همان‌گونه که آتش هیزم را می‌سوزاند.", + "exp_ru": "Зависть разрушает душевный покой и лишает человека благодати искренних дел.", + "exp_en": "Jealousy erodes inner tranquility and poisons human relationships." + } +] + +# Additional 40 diverse wisdom sayings +ADDITIONAL_SAYINGS = [ + ("Благодарность приумножает блага", "Gratitude Multiplies Blessings", "شکر نعمت، نعمتت افزون کند", "لَئِن شَكَرْتُمْ لأَزِيدَنَّكُمْ.", "Имам Али (а)", "Благодарность открывает двери изобилия."), + ("Умеренность в расходах", "Moderation in Expenditure", "میانه‌روی در معیشت", "حُسْنُ التَّدْبِيرِ مَعَ الْكَفَافِ خَيْرٌ مِنَ الْكَثِيرِ مَعَ الإِسْرَافِ.", "Имам Садик (а)", "Экономия и рассудительность сохраняют достаток."), + ("Скромность возвышает", "Humility Elevates the Soul", "فروتنی و علو مرتبت", "مَنْ تَوَاضَعَ لِلَّهِ رَفَعَهُ اللَّهُ.", "Пророк Мухаммад (с)", "Истинное величие заключается в смирении."), + ("Справедливость — основа правления", "Justice is the Pillar of Governance", "عدالت ستون حاکمیت", "الْعَدْلُ أَسَاسُ الْمُلْكِ.", "Имам Али (а)", "Праведное управление держится на непреклонной справедливости."), + ("Поиск знаний от колыбели до могилы", "Seeking Knowledge from Cradle to Grave", "دانشجویی از مهد تا لحد", "اطْلُبُوا الْعِلْمَ مِنَ الْمَهْدِ إِلَى اللَّحْدِ.", "Пророк Мухаммад (с)", "Обучение — это пожизненный долг верующего."), + ("Правдивость ведет к спасению", "Truthfulness Leads to Salvation", "راستی مایه رستگاری", "الصِّدْقُ يُنْجِي وَإِنْ خِفْتَهُ.", "Имам Али (а)", "Правда дарует уверенность и очищает совесть."), + ("Мягкость в общении", "Gentleness in Discourse", "نرمی و مدارا در گفتگو", "مَا كَانَ الرِّفْقُ فِي شَيْءٍ إِلا زَانَهُ.", "Пророк Мухаммад (с)", "Доброта и мягкость украшают любое дело."), + ("Бережливость времени", "The Value of Golden Hours", "غنیمت شمردن فرصت‌ها", "الْفُرْصَةُ تَمُرُّ مَرَّ السَّحَابِ.", "Имам Али (а)", "Время скоротечно, используйте возможности во благо."), + ("Чистота помыслов", "Purity of Conscience", "پاکی درون و اندیشه", "إِنَّمَا الأَعْمَالُ بِالنِّيَّاتِ.", "Пророк Мухаммад (с)", "Ценность поступка измеряется чистотой намерения."), + ("Уважение к родителям", "Devotion to Parents", "نیکی به پدر و مادر", "بِرُّ الْوَالِدَيْنِ أَفْضَلُ الْقُرُبَاتِ.", "Имам Бакир (а)", "Почитание родителей приносит благодать в оба мира.") +] + + +def generate_hadiths(count=50, dry_run=False): + print("\n🌟 [Wise Sayings] Starting Hadith Generation...") + print("=" * 70) + + # 1. Get the category + category = HadisCategory.objects.filter(slug="wise-sayings").first() + if not category: + category = HadisCategory.objects.filter(title__icontains="Wise Sayings").first() + if not category: + print("❌ Error: Category 'wise-sayings' not found in database!") + return + + print(f"📁 Target Category: ID {category.id} | Slug: '{category.slug}' | Sect: {category.sect_id}") + + # 2. Get dependencies + statuses = list(HadisStatus.objects.all()) + transmitters_pool = list(Transmitters.objects.all()) + book_refs = list(BookReference.objects.all()) + + # Fallback status + default_status = statuses[0] if statuses else None + + # Current max number for hadiths + max_num = Hadis.objects.all().order_by("-number").values_list("number", flat=True).first() or 0 + + print(f"📊 Current Highest Hadis Number: {max_num}") + print(f"📚 Available Transmitters: {len(transmitters_pool)} | Book References: {len(book_refs)}") + + # 3. Build 50 hadiths + created_count = 0 + hadis_instances = [] + transmitter_relations = [] + reference_relations = [] + + all_templates = list(WISDOM_TEMPLATES) + # Expand templates if count > 10 + idx = 0 + while len(all_templates) < count: + base_t = ADDITIONAL_SAYINGS[idx % len(ADDITIONAL_SAYINGS)] + idx += 1 + num_variant = idx + 10 + all_templates.append({ + "narrator_ru": base_t[4], + "narrator_en": "Infallible Imam (peace be upon him)", + "narrator_fa": "معصوم (ع)", + "title_ru": f"{base_t[0]} (Часть {num_variant})", + "title_en": f"{base_t[1]} (Part {num_variant})", + "title_fa": f"{base_t[2]} (بخش {num_variant})", + "text": base_t[3], + "trans_ru": f"{base_t[0]}: {base_t[5]}", + "trans_en": f"{base_t[1]}: Wisdom regarding character and faith.", + "trans_fa": f"{base_t[2]}: رهنمودی گرانبها در اخلاق و دینداری.", + "exp_ru": base_t[5], + "exp_en": f"Spiritual contemplation on {base_t[1]}." + }) + + for i in range(count): + tmpl = all_templates[i] + curr_number = max_num + i + 1 + + title_json = [ + {"language_code": "ru", "title": tmpl["title_ru"], "text": tmpl["title_ru"]}, + {"language_code": "en", "title": tmpl["title_en"], "text": tmpl["title_en"]}, + {"language_code": "fa", "title": tmpl["title_fa"], "text": tmpl["title_fa"]}, + ] + narrator_json = [ + {"language_code": "ru", "title": tmpl["narrator_ru"], "text": tmpl["narrator_ru"]}, + {"language_code": "en", "title": tmpl["narrator_en"], "text": tmpl["narrator_en"]}, + {"language_code": "fa", "title": tmpl["narrator_fa"], "text": tmpl["narrator_fa"]}, + ] + translation_json = [ + {"language_code": "ru", "title": tmpl["trans_ru"], "text": tmpl["trans_ru"]}, + {"language_code": "en", "title": tmpl["trans_en"], "text": tmpl["trans_en"]}, + {"language_code": "fa", "title": tmpl["trans_fa"], "text": tmpl["trans_fa"]}, + ] + explanation_json = [ + {"language_code": "ru", "text": tmpl["exp_ru"]}, + {"language_code": "en", "text": tmpl["exp_en"]}, + ] + + # Generate unique readable slug + base_slug_text = tmpl["title_en"] or tmpl["title_ru"] + clean_slug = slugify(base_slug_text, allow_unicode=True).strip('-').lower() + if len(clean_slug) > 60: + clean_slug = clean_slug[:60].rstrip('-') + slug_candidate = f"{clean_slug}-{curr_number}" + + hadis = Hadis( + category=category, + number=curr_number, + slug=slug_candidate, + title=title_json, + title_narrator=narrator_json, + text=tmpl["text"], + translation=translation_json, + explanation=explanation_json, + hadis_status=random.choice(statuses) if statuses else default_status, + links={"dovodi": "https://dovodi.newhorizonco.uk/"}, + status=True, + share_link=f"https://dovodi.newhorizonco.uk/arguments/hadith/{slug_candidate}" + ) + hadis_instances.append((hadis, tmpl)) + + print(f"✨ Prepared {len(hadis_instances)} Hadith records.") + + if dry_run: + print("\n[DRY RUN PREVIEW] First 5 Hadiths:") + for h, tmpl in hadis_instances[:5]: + print(f" • Hadis #{h.number:4d} | Slug: '{h.slug}' | Title: '{tmpl['title_en']}'") + print("\n✨ Dry run completed. No changes saved to database.") + return + + # Execute DB save + with transaction.atomic(): + print("💾 Saving Hadith records to database...") + # Save hadiths + saved_hadiths = [] + for h, tmpl in hadis_instances: + h.save() + saved_hadiths.append(h) + + # Attach transmitters and references + print("🔗 Linking transmitters and book references...") + for h in saved_hadiths: + # 2 to 4 transmitters + if transmitters_pool: + num_tr = min(len(transmitters_pool), random.randint(2, 4)) + sampled_tr = random.sample(transmitters_pool, num_tr) + gap_pos = random.randint(0, num_tr - 1) + + for pos, tr in enumerate(sampled_tr): + HadisTransmitter.objects.create( + hadis=h, + transmitter=tr, + chain_index=0, + order=pos, + is_gap=(pos == gap_pos) + ) + + # 1 Book reference + if book_refs: + ref = random.choice(book_refs) + HadisReference.objects.create( + hadis=h, + book_reference=ref, + volume=str(random.randint(1, 8)), + pages=str(random.randint(15, 450)), + hadith_number=str(h.number) + ) + + print("=" * 70) + print(f"✅ Successfully created {len(saved_hadiths)} Hadiths for category 'wise-sayings'!") + print(f"📌 Numbers range: #{saved_hadiths[0].number} to #{saved_hadiths[-1].number}") + + +def main(): + parser = argparse.ArgumentParser(description="Generate 50 Hadiths for the category 'wise-sayings'.") + parser.add_argument("--count", type=int, default=50, help="Number of hadiths to generate (default: 50).") + parser.add_argument("--dry-run", action="store_true", help="Preview without saving to DB.") + args = parser.parse_args() + + start_time = time.time() + generate_hadiths(count=args.count, dry_run=args.dry_run) + duration = time.time() - start_time + print(f"⏱ Completed in {duration:.2f} seconds.") + + +if __name__ == "__main__": + main() diff --git a/scripts/resolve_category_hadis_conflicts.py b/scripts/resolve_category_hadis_conflicts.py new file mode 100644 index 0000000..a7f7dd3 --- /dev/null +++ b/scripts/resolve_category_hadis_conflicts.py @@ -0,0 +1,95 @@ +import os +import sys +import argparse +import django + +sys.stdout.reconfigure(encoding='utf-8') +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.develop') +django.setup() + +from django.db import transaction +from django.db.models import Count +from apps.hadis.models import HadisCategory, Hadis + +def get_text(val): + if isinstance(val, list): + for item in val: + if isinstance(item, dict): + return item.get('text') or item.get('title') or str(item) + elif isinstance(val, dict): + return val.get('text') or val.get('title') or str(val) + return str(val or "") + +def resolve_category_conflicts(dry_run=True): + print("=" * 65) + print(" CATEGORY CONFLICT RESOLVER (HADITHS vs SUBCATEGORIES) ") + print(f" Mode: {'DRY RUN (Simulation only, no DB changes)' if dry_run else 'EXECUTE (Applying changes to DB)'}") + print("=" * 65) + + conflicted_categories = HadisCategory.objects.annotate( + hadis_count=Count('hadis', distinct=True), + children_count=Count('children', distinct=True) + ).filter(hadis_count__gt=0, children_count__gt=0) + + total_conflicts = conflicted_categories.count() + print(f"\nFound {total_conflicts} categories violating the rule (having both Hadiths & Subcategories).\n") + + if total_conflicts == 0: + print("✅ No conflicts found. All categories adhere to the business rule.") + return + + deleted_subcats_count = 0 + reassigned_hadis_count = 0 + + with transaction.atomic(): + for idx, parent_cat in enumerate(conflicted_categories, 1): + parent_title = get_text(parent_cat.title) + print(f"[{idx}/{total_conflicts}] Category #{parent_cat.id} ('{parent_title}') | Slug: {parent_cat.slug}") + print(f" - Direct Hadiths: {parent_cat.hadis_count}") + print(f" - Subcategories count: {parent_cat.children_count}") + + # Get all descendants + descendants = list(parent_cat.get_descendants().order_by('-level')) + print(f" - Total subcategories/descendants to remove: {len(descendants)}") + + for subcat in descendants: + subcat_title = get_text(subcat.title) + sub_hadis = subcat.hadis_set.all() + sub_hadis_count = sub_hadis.count() + + if sub_hadis_count > 0: + print(f" ⚠️ Subcategory #{subcat.id} ('{subcat_title}') has {sub_hadis_count} Hadiths!") + print(f" -> Reassigning these {sub_hadis_count} Hadiths to parent Category #{parent_cat.id} before deletion.") + if not dry_run: + sub_hadis.update(category=parent_cat) + reassigned_hadis_count += sub_hadis_count + + print(f" 🗑️ Removing Subcategory #{subcat.id} ('{subcat_title}')") + if not dry_run: + subcat.delete() + deleted_subcats_count += 1 + + if dry_run: + print("\n[DRY RUN SUMMARY]") + print(f" - Categories inspected: {total_conflicts}") + print(f" - Subcategories that would be deleted: {deleted_subcats_count}") + print(f" - Hadiths that would be safeguarded/reassigned: {reassigned_hadis_count}") + print(" -> No changes were written to the database.") + else: + print("\nRebuilding MPTT category tree structure...") + HadisCategory.objects.rebuild() + print("✅ MPTT category tree successfully rebuilt.") + + print("\n[EXECUTION SUMMARY]") + print(f" - Conflicted categories resolved: {total_conflicts}") + print(f" - Subcategories deleted: {deleted_subcats_count}") + print(f" - Hadiths safeguarded: {reassigned_hadis_count}") + print("✅ All changes committed successfully.") + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description="Resolve categories having both Hadiths and Subcategories") + parser.add_argument('--execute', action='store_true', help='Execute changes on database (default is dry-run)') + args = parser.parse_args() + + resolve_category_conflicts(dry_run=not args.execute) diff --git a/scripts/update_readable_slugs.py b/scripts/update_readable_slugs.py new file mode 100644 index 0000000..5af63ed --- /dev/null +++ b/scripts/update_readable_slugs.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +""" +High-Performance Script to Regenerate and Update Slugs for: +- HadisCategory +- Hadis +- Transmitters + +Features: +- Human-readable slugs generated from actual multilingual titles and names +- Fast in-memory deduplication and uniqueness guarantees +- Safe two-phase atomic update using raw SQL temporary assignment + bulk update +- Supports --dry-run flag for inspection before applying +""" + +import os +import sys +import re +import time +import argparse +from pathlib import Path +from django.utils.text import slugify + +# Force UTF-8 stdout for Windows consoles +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BASE_DIR)) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.base') + +import django +django.setup() + +from django.db import transaction, connection +from apps.hadis.models import HadisCategory, Hadis, Transmitters + +DUMMY_NAMES = { + "al-imam-al-hafiz-abu-isa-muhammad-ibn-isa-ibn-sawrah-ibn-musa-ibn-al-dahhak-al-sulami-al-tirmidhi", + "al-imam-al-hafiz-abu-isa-muhammad", + "الإمام الحافظ أبو عيسى محمد بن عيسى بن سَوْرَة بن موسى بن الضحاک السُّلَمي الترمذي", + "اسم-عربی", +} + + +def extract_best_text(json_val, preferred_langs=('en', 'ru', 'fa', 'ar')): + """Extract the best text from a multilingual JSONField (list of dicts).""" + if not json_val: + return "" + if isinstance(json_val, str): + return json_val.strip() + if isinstance(json_val, list): + for lang in preferred_langs: + for item in json_val: + if isinstance(item, dict) and item.get('language_code') == lang: + val = item.get('text') or item.get('title') or item.get('value') or item.get('name') + if val and str(val).strip(): + return str(val).strip() + for item in json_val: + if isinstance(item, dict): + val = item.get('text') or item.get('title') or item.get('value') or item.get('name') + if val and str(val).strip(): + return str(val).strip() + elif isinstance(item, str) and item.strip(): + return item.strip() + return "" + + +def extract_transmitter_text(transmitter): + """Extract best specific name for a transmitter, avoiding dummy/repeated texts.""" + candidates = [] + for field_val in (transmitter.full_name, transmitter.known_as, transmitter.nickname): + if not field_val or not isinstance(field_val, list): + continue + for lang in ('ru', 'fa', 'en', 'ar'): + for item in field_val: + if isinstance(item, dict) and item.get('language_code') == lang: + val = (item.get('text') or item.get('title') or '').strip() + if val and val not in DUMMY_NAMES and not val.startswith("313"): + candidates.append(val) + + if candidates: + return candidates[0] + + return extract_best_text(transmitter.full_name) or extract_best_text(transmitter.known_as) or "" + + +def clean_slug_text(text: str, max_len: int = 80) -> str: + """Clean and slugify text while preserving readability and length constraints.""" + if not text: + return "" + cleaned = re.sub(r'[\(\)\[\]\{\}\<\>\'\"\`\:\;\,\.\?\!\@\#\$\%\^\&\*\+\=\|\/\\]+', ' ', text) + slug = slugify(cleaned, allow_unicode=True).strip('-').lower() + if len(slug) > max_len: + slug = slug[:max_len].rstrip('-') + return slug + + +def generate_unique_slug(base_slug: str, used_slugs: set, max_len: int = 90) -> str: + """Ensure slug uniqueness using an in-memory set with counter suffix.""" + if not base_slug: + base_slug = "item" + + candidate = base_slug + counter = 1 + + while candidate in used_slugs: + counter += 1 + suffix = f"-{counter}" + avail_len = max_len - len(suffix) + candidate = f"{base_slug[:avail_len].rstrip('-')}{suffix}" + + used_slugs.add(candidate) + return candidate + + +def update_categories_slugs(dry_run=False): + print("\n📂 [1/3] Processing HadisCategory...") + categories = list(HadisCategory.objects.all().order_by('id')) + used_slugs = set() + sample_changes = [] + + for cat in categories: + raw_text = extract_best_text(cat.title, ('en', 'ru', 'fa', 'ar')) or extract_best_text(cat.description) + base_slug = clean_slug_text(raw_text, max_len=75) + if not base_slug: + base_slug = f"category-{cat.source_type or 'item'}-{cat.id}" + + new_slug = generate_unique_slug(base_slug, used_slugs) + + if len(sample_changes) < 5 and cat.slug != new_slug: + sample_changes.append((cat.id, cat.slug, new_slug, raw_text)) + + cat.slug = new_slug + + print(f" Total categories: {len(categories)}") + for item_id, old_s, new_s, title in sample_changes: + print(f" • ID {item_id:4d} | '{title[:30]}' -> {old_s} ➔ {new_s}") + + if not dry_run: + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("UPDATE hadis_hadiscategory SET slug = CONCAT('tmp-c-', id);") + HadisCategory.objects.bulk_update(categories, ['slug'], batch_size=1000) + print(" ✅ All categories updated successfully.") + + +def update_hadis_slugs(dry_run=False): + print("\n📜 [2/3] Processing Hadis...") + hadiths = list(Hadis.objects.all().order_by('id')) + used_slugs = set() + sample_changes = [] + + for h in hadiths: + raw_text = extract_best_text(h.title, ('en', 'ru', 'fa', 'ar')) or extract_best_text(h.translation) or extract_best_text(h.hadis_status_text) + base_slug = clean_slug_text(raw_text, max_len=75) + + if not base_slug or (base_slug.isdigit() and len(base_slug) <= 3): + if h.number: + base_slug = f"hadis-{h.number}" + (f"-{base_slug}" if base_slug else "") + else: + base_slug = f"hadis-{h.id}" + + new_slug = generate_unique_slug(base_slug, used_slugs) + + if len(sample_changes) < 5 and h.slug != new_slug: + sample_changes.append((h.id, h.slug, new_slug, raw_text or str(h.number))) + + h.slug = new_slug + + print(f" Total hadiths: {len(hadiths)}") + for item_id, old_s, new_s, title in sample_changes: + print(f" • ID {item_id:4d} | '{title[:30]}' -> {old_s} ➔ {new_s}") + + if not dry_run: + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("UPDATE hadis_hadis SET slug = CONCAT('tmp-h-', id);") + Hadis.objects.bulk_update(hadiths, ['slug'], batch_size=1000) + print(" ✅ All hadiths updated successfully.") + + +def update_transmitters_slugs(dry_run=False): + print("\n👤 [3/3] Processing Transmitters...") + transmitters = list(Transmitters.objects.all().order_by('id')) + used_slugs = set() + sample_changes = [] + + for tr in transmitters: + raw_text = extract_transmitter_text(tr) + base_slug = clean_slug_text(raw_text, max_len=75) + + if not base_slug: + base_slug = f"transmitter-{tr.id}" + + new_slug = generate_unique_slug(base_slug, used_slugs) + + if len(sample_changes) < 5 and tr.slug != new_slug: + sample_changes.append((tr.id, tr.slug, new_slug, raw_text)) + + tr.slug = new_slug + + print(f" Total transmitters: {len(transmitters)}") + for item_id, old_s, new_s, name in sample_changes: + print(f" • ID {item_id:4d} | '{name[:30]}' -> {old_s} ➔ {new_s}") + + if not dry_run: + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("UPDATE hadis_transmitters SET slug = CONCAT('tmp-t-', id);") + Transmitters.objects.bulk_update(transmitters, ['slug'], batch_size=1000) + print(" ✅ All transmitters updated successfully.") + + +def main(): + parser = argparse.ArgumentParser(description="Update readable slugs for Categories, Hadiths, and Transmitters.") + parser.add_argument("--dry-run", action="store_true", help="Simulate slug generation without saving to database.") + args = parser.parse_args() + + start_time = time.time() + mode_text = "DRY RUN (Preview Only)" if args.dry_run else "EXECUTE (Database Update)" + print(f"\n🚀 Starting Slug Optimization [{mode_text}]...") + print("=" * 70) + + update_categories_slugs(dry_run=args.dry_run) + update_hadis_slugs(dry_run=args.dry_run) + update_transmitters_slugs(dry_run=args.dry_run) + + duration = time.time() - start_time + print("\n" + "=" * 70) + print(f"✨ Completed in {duration:.2f} seconds!") + + +if __name__ == "__main__": + main() diff --git a/utils/pagination.py b/utils/pagination.py index 39ab359..24665a1 100644 --- a/utils/pagination.py +++ b/utils/pagination.py @@ -30,4 +30,11 @@ class NoPagination(PageNumberPagination): 'next': None, 'previous': None, 'results': data, - }) \ No newline at end of file + }) + + +class OptionalPagination(StandardResultsSetPagination): + def paginate_queryset(self, queryset, request, view=None): + if 'page' in request.query_params or 'page_size' in request.query_params: + return super().paginate_queryset(queryset, request, view) + return None \ No newline at end of file