diff --git a/apps/hadis/migrations/0050_hadis_categories.py b/apps/hadis/migrations/0050_hadis_categories.py new file mode 100644 index 0000000..372cf16 --- /dev/null +++ b/apps/hadis/migrations/0050_hadis_categories.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.30 on 2026-10-13 11:19 + +from django.db import migrations, models + + +def populate_categories_from_category(apps, schema_editor): + Hadis = apps.get_model('hadis', 'Hadis') + HadisCategoryThrough = Hadis.categories.through + items_to_create = [] + for hadis in Hadis.objects.filter(category__isnull=False).only('id', 'category_id'): + items_to_create.append( + HadisCategoryThrough(hadis_id=hadis.id, hadiscategory_id=hadis.category_id) + ) + if items_to_create: + HadisCategoryThrough.objects.bulk_create(items_to_create, ignore_conflicts=True) + + +def reverse_populate(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0049_alter_bookreference_normalized_text_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='hadis', + name='categories', + field=models.ManyToManyField(blank=True, related_name='hadis_items', to='hadis.hadiscategory', verbose_name='categories'), + ), + migrations.RunPython(populate_categories_from_category, reverse_populate), + ] diff --git a/apps/hadis/models/category.py b/apps/hadis/models/category.py index a0a7e21..4f3117d 100644 --- a/apps/hadis/models/category.py +++ b/apps/hadis/models/category.py @@ -93,11 +93,16 @@ class HadisCategory(LowercaseSlugMixin, MPTTModel): f'Parent sect: {self.parent.sect.sect_type}, ' f'Your sect: {self.sect.sect_type}') ) - if self.parent and self.parent.hadis_set.exists(): + parent_has_hadis = self.parent and ( + self.parent.hadis_set.exists() or + (hasattr(self.parent, 'hadis_items') and self.parent.hadis_items.exists()) + ) + if parent_has_hadis: raise ValidationError( _('Cannot set parent to a category that already contains hadiths/content items.') ) - if self.pk and self.hadis_set.exists() and self.children.exists(): + has_hadis = self.hadis_set.exists() or (hasattr(self, 'hadis_items') and self.hadis_items.exists()) + if self.pk and has_hadis and self.children.exists(): raise ValidationError( _('A category cannot have both hadiths and subcategories.') ) diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py index f3f1e6e..cf39e36 100644 --- a/apps/hadis/models/hadis.py +++ b/apps/hadis/models/hadis.py @@ -157,6 +157,7 @@ class HadisStatus(ColorPaletteMixin, LowercaseSlugMixin, models.Model): class Hadis(LowercaseSlugMixin, models.Model): slug_source_field = 'title' category = models.ForeignKey("hadis.HadisCategory", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('category')) + categories = models.ManyToManyField("hadis.HadisCategory", related_name="hadis_items", blank=True, verbose_name=_('categories')) number = models.PositiveIntegerField(verbose_name=_('number'), default=1) legacy_id = models.CharField(max_length=255, unique=True, null=True, blank=True, db_index=True) slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True, allow_unicode=True) @@ -202,6 +203,12 @@ class Hadis(LowercaseSlugMixin, models.Model): raise ValidationError( _('Cannot assign hadith to a category that has subcategories. Only leaf categories can contain hadiths.') ) + if self.pk: + for cat in self.categories.all(): + if cat.children.exists(): + raise ValidationError( + _('Cannot assign hadith to a category that has subcategories. Only leaf categories can contain hadiths.') + ) def save(self, *args, **kwargs): self.clean() diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py index 9bf19cc..9439ddf 100644 --- a/apps/hadis/serializers/hadis.py +++ b/apps/hadis/serializers/hadis.py @@ -1374,56 +1374,54 @@ class HadisCorrectionSerializer(serializers.ModelSerializer): "thumbnail": url, }) return images_list - + + +def serialize_hadis_category_item(cat, request): + if not cat: + return None + return { + 'id': cat.id, + 'title': get_localized_text(cat.title, request), + 'slug': cat.slug, + 'source_type': cat.source_type, + 'sect_type': cat.sect.sect_type if hasattr(cat, 'sect') and cat.sect else 'shia', + } + class HadisBasicSerializer(serializers.ModelSerializer): """Basic serializer for Hadis with minimal information""" translation = LocalizedField() category = serializers.SerializerMethodField() - bookmark= serializers.SerializerMethodField() + categories = serializers.SerializerMethodField() + bookmark = serializers.SerializerMethodField() title = LocalizedField() title_narrator = LocalizedField() explanation = LocalizedField() # explanations = serializers.SerializerMethodField() - + class Meta: model = Hadis fields = [ - 'id', 'slug', 'title','bookmark', 'title_narrator', 'text', - 'translation', 'share_link','explanation','category' + 'id', 'slug', 'title', 'bookmark', 'title_narrator', 'text', + 'translation', 'share_link', 'explanation', 'category', 'categories' ] - - # def get_explanations(self, obj): - # """Get structured explanations for the requested language""" - # request = self.context.get('request') - # request_lang = request.query_params.get('lang', 'en') if request else 'en' - - # explanations_list = [] - # if obj.explanations and isinstance(obj.explanations, list): - # for item in obj.explanations: - # if isinstance(item, dict) and item.get('language_code') == request_lang: - # explanations_list.append({ - # 'title': item.get('title', ''), - # 'description': item.get('description', '') - # }) - - # return explanations_list if explanations_list else None - def get_category(self, obj): - """Get category id and title""" - if obj.category: - request = self.context.get('request') + cat = obj.category + if not cat and hasattr(obj, 'categories'): + cat = obj.categories.first() + if not cat: + return None + return serialize_hadis_category_item(cat, self.context.get('request')) + + def get_categories(self, obj): + request = self.context.get('request') + cats = list(obj.categories.all().select_related('sect')) + if not cats and obj.category: + cats = [obj.category] + return [serialize_hadis_category_item(c, request) for c in cats] - return { - 'id': obj.category.id, - 'title': get_localized_text(obj.title,request), - 'slug':obj.category.slug, - 'source_type':obj.category.source_type, - 'sect_type':obj.category.sect.sect_type - } - return None def get_bookmark(self, obj): """Get bookmark information for this playlist.""" request = self.context.get('request') @@ -1443,12 +1441,12 @@ class HadisShortSerializer(serializers.ModelSerializer): title = LocalizedField() title_narrator = LocalizedField() bookmark = serializers.SerializerMethodField() - + class Meta: model = Hadis fields = [ 'id', 'slug', 'title', 'title_narrator', 'text', - 'translation', 'share_link', 'bookmark'] + 'translation', 'share_link', 'bookmark'] def get_bookmark(self, obj): """Get bookmark status for this Hadis.""" @@ -1462,7 +1460,6 @@ class HadisShortSerializer(serializers.ModelSerializer): return book_mark.get('is_bookmarked', False) - class HadisDetailSerializer(serializers.ModelSerializer): """Detailed serializer for Hadis with core information (transmitters and corrections moved to separate endpoints)""" hadis_status = HadisStatusSerializer(read_only=True) @@ -1475,19 +1472,30 @@ class HadisDetailSerializer(serializers.ModelSerializer): reference_images = SerializerMethodField() address = LocalizedField() category_slug = serializers.CharField(source='category.slug', read_only=True) + category = serializers.SerializerMethodField() + categories = serializers.SerializerMethodField() class Meta: model = Hadis fields = [ - 'id', 'number', 'slug', 'category_id', 'category_slug', 'hadis_status', 'status_text', 'links', 'share_link', + 'id', 'number', 'slug', 'category_id', 'category_slug', 'category', 'categories', 'hadis_status', 'status_text', 'links', 'share_link', 'tags', 'references', 'reference_images', 'address' ] - - # def get_address_details(self, obj): - # """Get address details sorted by priority""" - # if obj.address_details and isinstance(obj.address_details, list): - # return sorted(obj.address_details, key=lambda x: x.get('priority', 0)) - # return [] + + def get_category(self, obj): + cat = obj.category + if not cat and hasattr(obj, 'categories'): + cat = obj.categories.first() + if not cat: + return None + return serialize_hadis_category_item(cat, self.context.get('request')) + + def get_categories(self, obj): + request = self.context.get('request') + cats = list(obj.categories.all().select_related('sect')) + if not cats and obj.category: + cats = [obj.category] + return [serialize_hadis_category_item(c, request) for c in cats] def get_reference_images(self, obj): """Get all reference images from all references""" diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index f91848a..55f7033 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/apps/hadis/serializers/serializers_admin.py @@ -1,7 +1,7 @@ from rest_framework import serializers from django.core.files.uploadedfile import SimpleUploadedFile from django.utils.translation import gettext_lazy as _ -from django.db.models import Exists, OuterRef +from django.db.models import Exists, OuterRef, Q from utils.image_compression import maybe_compress_uploaded_file from utils import absolute_https_url @@ -80,7 +80,7 @@ class AdminHadisCategorySerializer(serializers.ModelSerializer): def get_has_hadith(self, obj): if hasattr(obj, "annotated_has_hadith"): return bool(obj.annotated_has_hadith) - return obj.hadis_set.exists() + return obj.hadis_set.exists() or (hasattr(obj, "hadis_items") and obj.hadis_items.exists()) def get_has_quran_verse(self, obj): if hasattr(obj, "annotated_has_quran_verse"): @@ -152,6 +152,7 @@ class AdminHadisCategoryListSerializer(serializers.ModelSerializer): class AdminHadisListSerializer(serializers.ModelSerializer): category_detail = AdminHadisCategoryListSerializer(source="category", read_only=True) + categories_detail = AdminHadisCategoryListSerializer(source="categories", many=True, read_only=True) hadis_status_detail = AdminHadisStatusSerializer(source="hadis_status", read_only=True) collections_detail = serializers.SerializerMethodField() @@ -168,6 +169,8 @@ class AdminHadisListSerializer(serializers.ModelSerializer): "status", "category", "category_detail", + "categories", + "categories_detail", "hadis_status", "hadis_status_detail", "collections_detail", @@ -415,6 +418,7 @@ class AdminHadisReferenceSerializer(serializers.ModelSerializer): class AdminHadisDetailSerializer(serializers.ModelSerializer): category_detail = AdminHadisCategorySerializer(source="category", read_only=True) + categories_detail = AdminHadisCategorySerializer(source="categories", many=True, read_only=True) hadis_status_detail = AdminHadisStatusSerializer(source="hadis_status", read_only=True) tags_detail = AdminHadisTagSerializer(source="tags", many=True, read_only=True) @@ -423,6 +427,11 @@ class AdminHadisDetailSerializer(serializers.ModelSerializer): required=False, allow_null=True, ) + categories = serializers.PrimaryKeyRelatedField( + queryset=HadisCategory.objects.all(), + many=True, + required=False, + ) hadis_status = serializers.PrimaryKeyRelatedField( queryset=HadisStatus.objects.all(), required=False, @@ -441,6 +450,7 @@ class AdminHadisDetailSerializer(serializers.ModelSerializer): ) collections_detail = serializers.SerializerMethodField() category_path = serializers.SerializerMethodField() + categories_paths = serializers.SerializerMethodField() book_reference_id = serializers.PrimaryKeyRelatedField( queryset=BookReference.objects.all(), required=False, @@ -481,9 +491,12 @@ class AdminHadisDetailSerializer(serializers.ModelSerializer): bookmark_count = serializers.SerializerMethodField() def get_category_path(self, obj): - if not obj.category: + cat = obj.category + if not cat and hasattr(obj, "categories"): + cat = obj.categories.first() + if not cat: return [] - ancestors_qs = obj.category.get_ancestors(include_self=True).select_related('sect') + ancestors_qs = cat.get_ancestors(include_self=True).select_related('sect') return [ { "id": a.id, @@ -498,6 +511,28 @@ class AdminHadisDetailSerializer(serializers.ModelSerializer): for a in ancestors_qs ] + def get_categories_paths(self, obj): + cats = list(obj.categories.all()) if hasattr(obj, "categories") else [] + if not cats and obj.category: + cats = [obj.category] + all_paths = [] + for cat in cats: + ancestors_qs = cat.get_ancestors(include_self=True).select_related('sect') + all_paths.append([ + { + "id": a.id, + "title": a.title, + "slug": a.slug, + "source_type": a.source_type, + "sect_type": a.sect.sect_type if a.sect else None, + "order": a.order, + "is_root": a.parent_id is None, + "level": getattr(a, a._mptt_meta.level_attr) if hasattr(a, '_mptt_meta') else 0, + } + for a in ancestors_qs + ]) + return all_paths + class Meta: model = Hadis fields = [ @@ -507,6 +542,9 @@ class AdminHadisDetailSerializer(serializers.ModelSerializer): "category", "category_detail", "category_path", + "categories", + "categories_detail", + "categories_paths", "title_narrator", "title", "description", @@ -631,6 +669,14 @@ class AdminHadisDetailSerializer(serializers.ModelSerializer): ) return value + def validate_categories(self, values): + for val in values: + if val and val.children.exists(): + raise serializers.ValidationError( + _("Cannot assign a hadith/item to a category that has subcategories. Only leaf categories can contain hadiths.") + ) + return values + def get_collections_detail(self, obj): collections = [item.collection for item in obj.collection_items.all()] return AdminHadisCollectionSerializer(collections, many=True).data @@ -644,7 +690,15 @@ class AdminHadisDetailSerializer(serializers.ModelSerializer): hadith_number = validated_data.pop("hadith_number", None) tags = validated_data.pop("tags", []) collections = validated_data.pop("collection_items", []) + categories = validated_data.pop("categories", None) hadis = super().create(validated_data) + if categories is not None: + hadis.categories.set(categories) + if not hadis.category and categories: + hadis.category = categories[0] + hadis.save(update_fields=['category']) + elif hadis.category: + hadis.categories.set([hadis.category]) if tags: hadis.tags.set(tags) if collections: @@ -678,7 +732,17 @@ class AdminHadisDetailSerializer(serializers.ModelSerializer): tags = validated_data.pop("tags", None) collections = validated_data.pop("collection_items", None) + categories = validated_data.pop("categories", None) hadis = super().update(instance, validated_data) + if categories is not None: + hadis.categories.set(categories) + if not hadis.category and categories: + hadis.category = categories[0] + hadis.save(update_fields=['category']) + elif "category" in validated_data and validated_data.get("category"): + if not hadis.categories.exists(): + hadis.categories.set([validated_data["category"]]) + if tags is not None: hadis.tags.set(tags) if collections is not None: @@ -1656,7 +1720,13 @@ class AdminHadisCategoryArchiveDetailSerializer(AdminHadisCategoryArchiveSeriali has_children = bool(getattr(obj, "annotated_has_children", obj.children.exists())) if has_children: return "categories" - has_hadith = bool(getattr(obj, "annotated_has_hadith", obj.hadis_set.exists())) + has_hadith = bool( + getattr( + obj, + "annotated_has_hadith", + obj.hadis_set.exists() or (hasattr(obj, "hadis_items") and obj.hadis_items.exists()) + ) + ) if has_hadith: return "arguments" return "none" @@ -1664,7 +1734,7 @@ class AdminHadisCategoryArchiveDetailSerializer(AdminHadisCategoryArchiveSeriali def get_child_categories(self, obj): if not obj or not obj.pk: return [] - child_hadis_subquery = Hadis.objects.filter(category=OuterRef("pk")) + child_hadis_subquery = Hadis.objects.filter(Q(category=OuterRef("pk")) | Q(categories=OuterRef("pk"))) child_children_subquery = HadisCategory.objects.filter(parent=OuterRef("pk")) children = obj.children.all().annotate( annotated_has_hadith=Exists(child_hadis_subquery), @@ -1686,6 +1756,9 @@ class AdminHadisCategoryArchiveDetailSerializer(AdminHadisCategoryArchiveSeriali def get_child_arguments(self, obj): if not obj or not obj.pk: return [] + hadis_qs = Hadis.objects.filter( + Q(category=obj) | Q(categories=obj) + ).distinct().select_related("hadis_status").order_by("number", "-id")[:100] return [ { "id": h.id, @@ -1701,7 +1774,7 @@ class AdminHadisCategoryArchiveDetailSerializer(AdminHadisCategoryArchiveSeriali "main_color_code": getattr(h.hadis_status, "main_color_code", None), } if h.hadis_status else None, } - for h in obj.hadis_set.all().select_related("hadis_status").order_by("number", "-id")[:100] + for h in hadis_qs ] def get_child_count(self, obj): @@ -1710,7 +1783,7 @@ class AdminHadisCategoryArchiveDetailSerializer(AdminHadisCategoryArchiveSeriali cat_count = obj.children.count() if cat_count > 0: return cat_count - return obj.hadis_set.count() + return Hadis.objects.filter(Q(category=obj) | Q(categories=obj)).distinct().count() class Meta(AdminHadisCategoryArchiveSerializer.Meta): fields = AdminHadisCategoryArchiveSerializer.Meta.fields + [ diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py index 48c65b0..2750d0f 100644 --- a/apps/hadis/views/hadis.py +++ b/apps/hadis/views/hadis.py @@ -337,9 +337,9 @@ class HadisListView(ListAPIView): return Hadis.objects.none() queryset = Hadis.objects.filter( - category__slug=category_slug, + Q(category__slug=category_slug) | Q(categories__slug=category_slug), status=True - ).annotate( + ).distinct().annotate( layer_count=Count('transmitters__narrator_layer', distinct=True) ).select_related('category').prefetch_related( 'tags', @@ -929,11 +929,13 @@ class CategoryHadisCorrectionsView(ListAPIView): 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) + hadis_ids = Hadis.objects.filter( + Q(category_id__in=category_ids) | Q(categories__id__in=category_ids), + status=True + ).distinct().values_list('id', flat=True) + else: + hadis_ids = Hadis.objects.filter(status=True).values_list('id', flat=True) queryset = HadisCorrection.objects.filter( hadis_id__in=hadis_ids diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index c1a54ce..daac840 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -65,7 +65,7 @@ class AdminHadisCategoryViewSet(ReadOnlyModelViewSet): pagination_class = OptionalPagination def get_queryset(self): - hadis_subquery = Hadis.objects.filter(category=OuterRef("pk")) + hadis_subquery = Hadis.objects.filter(Q(category=OuterRef("pk")) | Q(categories=OuterRef("pk"))) children_subquery = HadisCategory.objects.filter(parent=OuterRef("pk")) quran_verse_subquery = CategoryQuranVerse.objects.filter(category=OuterRef("pk")) queryset = HadisCategory.objects.select_related("parent", "sect").annotate( @@ -153,7 +153,7 @@ class DovodiHadisCategoryViewSet(ModelViewSet): return AdminHadisCategoryArchiveSerializer def get_queryset(self): - hadis_subquery = Hadis.objects.filter(category=OuterRef("pk")) + hadis_subquery = Hadis.objects.filter(Q(category=OuterRef("pk")) | Q(categories=OuterRef("pk"))) children_subquery = HadisCategory.objects.filter(parent=OuterRef("pk")) queryset = HadisCategory.objects.select_related("parent", "sect").annotate( annotated_has_hadith=Exists(hadis_subquery), @@ -409,7 +409,7 @@ class AdminHadisViewSet(ModelViewSet): queryset = ( Hadis.objects.all() .select_related("category", "hadis_status") - .prefetch_related("collection_items__collection") + .prefetch_related("categories", "collection_items__collection") ) search_query = self.request.query_params.get("search") @@ -427,15 +427,15 @@ class AdminHadisViewSet(ModelViewSet): category_id = self.request.query_params.get("category") if category_id and category_id != "all": - queryset = queryset.filter(category_id=category_id) + queryset = queryset.filter(Q(category_id=category_id) | Q(categories__id=category_id)).distinct() 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) + queryset = queryset.filter(collection_items__collection_id=collection_id).distinct() source_type = self.request.query_params.get("source_type") if source_type: - queryset = queryset.filter(category__source_type=source_type) + queryset = queryset.filter(Q(category__source_type=source_type) | Q(categories__source_type=source_type)).distinct() status_filter = self.request.query_params.get("status") if status_filter in {"true", "false"}: