Browse Source

feat(hadis): add multi-category M2M support with full backend integration

Models:
- Hadis: add categories ManyToManyField to HadisCategory (related_name=hadis_items)
- Hadis.clean(): validate that none of the M2M categories have subcategories
- HadisCategory.clean(): update parent/leaf validation to also check hadis_items

Migration:
- 0050_hadis_categories: create hadis_categories junction table

Serializers (Admin):
- AdminHadisDetailSerializer: add categories (PK writable), categories_detail (read-only nested), and categories_paths (full ancestor path per category)
- validate_categories(): enforce leaf-only constraint on M2M selection
- create/update: sync categories M2M; auto-set FK category from first M2M item
- AdminHadisCategoryArchiveDetailSerializer: update child_arguments and child_count to use Q(category|categories)

Views (Admin):
- AdminHadisCategoryViewSet: annotate has_hadith using Q(category|categories)
- AdminHadisViewSet: prefetch categories; filter category_id, collection_id, source_type with Q() + distinct()

Views (Public):
- HadisListView: filter by category slug across FK and M2M using Q() + distinct()
- CategoryHadisCorrectionsView: filter corrections via M2M-aware category_id__in query
master
Mohsen Taba 3 weeks ago
parent
commit
245098f356
  1. 35
      apps/hadis/migrations/0050_hadis_categories.py
  2. 9
      apps/hadis/models/category.py
  3. 7
      apps/hadis/models/hadis.py
  4. 82
      apps/hadis/serializers/hadis.py
  5. 89
      apps/hadis/serializers/serializers_admin.py
  6. 14
      apps/hadis/views/hadis.py
  7. 12
      apps/hadis/views_admin.py

35
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),
]

9
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.')
)

7
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()

82
apps/hadis/serializers/hadis.py

@ -1376,11 +1376,24 @@ class HadisCorrectionSerializer(serializers.ModelSerializer):
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()
@ -1390,40 +1403,25 @@ class HadisBasicSerializer(serializers.ModelSerializer):
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:
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')
@ -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"""

89
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 + [

14
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

12
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"}:

Loading…
Cancel
Save