From bb5c57d0b73d48ddde128c209607beb3274e2986 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Fri, 16 Oct 2026 13:09:34 +0330 Subject: [PATCH] feat(hadis): implement is_topic architectural separation for categories and topics - Add is_topic boolean field to HadisCategory model with indexing - Enforce validation rules in clean(): * Topics cannot have children and must define a source_type * Structural categories cannot contain hadiths and have source_type=None * Parents cannot be topics * Hadiths can only be attached to topics (is_topic=True) - Update admin serializers and views to support is_topic filtering and mapping - Expose is_topic in public category serializers (SimpleCategory, HadisCategorySelectSerializer, CategorySerializer) - Include migration 0051_add_category_is_topic --- .../migrations/0051_add_category_is_topic.py | 34 ++++++ apps/hadis/models/category.py | 109 +++++++++++------- apps/hadis/models/hadis.py | 17 ++- apps/hadis/serializers/category.py | 15 +-- apps/hadis/serializers/serializers_admin.py | 43 ++++++- apps/hadis/views/category.py | 3 +- apps/hadis/views_admin.py | 8 +- 7 files changed, 170 insertions(+), 59 deletions(-) create mode 100644 apps/hadis/migrations/0051_add_category_is_topic.py diff --git a/apps/hadis/migrations/0051_add_category_is_topic.py b/apps/hadis/migrations/0051_add_category_is_topic.py new file mode 100644 index 0000000..ab18f79 --- /dev/null +++ b/apps/hadis/migrations/0051_add_category_is_topic.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.30 on 2026-10-15 09:29 + +from django.db import migrations, models + + +def populate_is_topic(apps, schema_editor): + HadisCategory = apps.get_model('hadis', 'HadisCategory') + # All categories that have children -> is_topic=False, source_type=None + parent_ids = list(HadisCategory.objects.filter(children__isnull=False).values_list('id', flat=True).distinct()) + HadisCategory.objects.filter(id__in=parent_ids).update(is_topic=False, source_type=None) + + # All leaf categories (no children) -> is_topic=True + HadisCategory.objects.exclude(id__in=parent_ids).update(is_topic=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0050_hadis_categories'), + ] + + operations = [ + migrations.AddField( + model_name='hadiscategory', + name='is_topic', + field=models.BooleanField(db_index=True, default=False, verbose_name='Is Topic'), + ), + migrations.AlterField( + model_name='hadiscategory', + name='source_type', + field=models.CharField(blank=True, choices=[('quran', 'Quran'), ('hadith', 'Hadith'), ('history', 'History'), ('fatwa', 'Fatwa'), ('quote', 'Quote')], max_length=10, null=True, verbose_name='Source Type'), + ), + migrations.RunPython(populate_is_topic, reverse_code=migrations.RunPython.noop), + ] diff --git a/apps/hadis/models/category.py b/apps/hadis/models/category.py index 4f3117d..123753e 100644 --- a/apps/hadis/models/category.py +++ b/apps/hadis/models/category.py @@ -74,7 +74,18 @@ class HadisCategory(LowercaseSlugMixin, MPTTModel): parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children') sect = models.ForeignKey(HadisSect, on_delete=models.PROTECT, verbose_name=_('Sect'), null=False, blank=False) - source_type = models.CharField(max_length=10, choices=SourceType.choices, verbose_name=_('Source Type')) + source_type = models.CharField( + max_length=10, + choices=SourceType.choices, + verbose_name=_('Source Type'), + null=True, + blank=True + ) + is_topic = models.BooleanField( + default=False, + db_index=True, + verbose_name=_('Is Topic') + ) title = models.JSONField(default = list , verbose_name=_('Title')) description = models.JSONField(default = list , blank=True, verbose_name=_('Description')) order = models.IntegerField(default=0, verbose_name=_('order')) @@ -87,25 +98,48 @@ class HadisCategory(LowercaseSlugMixin, MPTTModel): def clean(self): super().clean() - if self.parent and self.sect_id != self.parent.sect_id: - raise ValidationError( - _('Child category must have the same sect_type as its parent. ' - f'Parent sect: {self.parent.sect.sect_type}, ' - f'Your sect: {self.sect.sect_type}') - ) - 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.parent: + if getattr(self.parent, 'is_topic', False): + raise ValidationError( + _('Cannot set parent to a topic. Parent must be a structural category.') + ) + if self.sect_id != self.parent.sect_id: + raise ValidationError( + _('Child category must have the same sect_type as its parent. ' + f'Parent sect: {self.parent.sect.sect_type}, ' + f'Your sect: {self.sect.sect_type}') + ) + parent_has_hadis = ( + self.parent.hadis_set.exists() or + (hasattr(self.parent, 'hadis_items') and self.parent.hadis_items.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.') + if parent_has_hadis: + raise ValidationError( + _('Cannot set parent to a category that already contains hadiths/content items.') + ) + + if self.is_topic: + if not self.source_type: + raise ValidationError( + {'source_type': _('A topic must have a source type.')} + ) + if self.pk and self.children.exists(): + raise ValidationError( + _('A topic cannot have subcategories.') + ) + else: + # Structural category: clear source_type if set + if self.source_type: + self.source_type = None + + has_hadis = self.pk and ( + self.hadis_set.exists() or + (hasattr(self, 'hadis_items') and self.hadis_items.exists()) ) + if has_hadis: + raise ValidationError( + _('A structural category cannot have hadiths/content items attached. Only topics can contain hadiths.') + ) slug_source_field = 'title' @@ -122,29 +156,22 @@ class HadisCategory(LowercaseSlugMixin, MPTTModel): def share_link(self): if not self.slug: return None - has_children = getattr(self, "annotated_has_children", None) - if has_children is None: - if self.pk: - has_children = self.children.exists() - else: - has_children = False - - if has_children: - sect_type = "shia" - if self.sect_id: - try: - if hasattr(self, "sect") and self.sect: - sect_type = getattr(self.sect, "sect_type", "shia") - else: - from .category import HadisSect - sect = HadisSect.objects.filter(id=self.sect_id).first() - if sect: - sect_type = sect.sect_type - except Exception: - sect_type = "shia" - return f"{settings.DOVODI_DOMAIN}/arguments/categories/{sect_type}/{self.slug}" - - return f"{settings.DOVODI_DOMAIN}/arguments/category/{self.slug}" + if self.is_topic: + return f"{settings.DOVODI_DOMAIN}/arguments/category/{self.slug}" + + sect_type = "shia" + if self.sect_id: + try: + if hasattr(self, "sect") and self.sect: + sect_type = getattr(self.sect, "sect_type", "shia") + else: + from .category import HadisSect + sect = HadisSect.objects.filter(id=self.sect_id).first() + if sect: + sect_type = sect.sect_type + except Exception: + sect_type = "shia" + return f"{settings.DOVODI_DOMAIN}/arguments/categories/{sect_type}/{self.slug}" @property def xmind_share_link(self): diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py index cf39e36..db4308c 100644 --- a/apps/hadis/models/hadis.py +++ b/apps/hadis/models/hadis.py @@ -199,12 +199,21 @@ class Hadis(LowercaseSlugMixin, models.Model): def clean(self): super().clean() - if self.category and self.category.children.exists(): - raise ValidationError( - _('Cannot assign hadith to a category that has subcategories. Only leaf categories can contain hadiths.') - ) + if self.category: + if not getattr(self.category, 'is_topic', False): + raise ValidationError( + _('Cannot assign hadith to a structural category. Only topics can contain hadiths.') + ) + if self.category.children.exists(): + 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 not getattr(cat, 'is_topic', False): + raise ValidationError( + _('Cannot assign hadith to a structural category. Only topics can contain hadiths.') + ) if cat.children.exists(): raise ValidationError( _('Cannot assign hadith to a category that has subcategories. Only leaf categories can contain hadiths.') diff --git a/apps/hadis/serializers/category.py b/apps/hadis/serializers/category.py index 527c459..2e8b5e8 100644 --- a/apps/hadis/serializers/category.py +++ b/apps/hadis/serializers/category.py @@ -176,7 +176,7 @@ class SimpleCategory(serializers.ModelSerializer): class Meta: model = HadisCategory - fields = ['id', 'title', 'slug', 'source_type', 'sect_type', 'description', 'ancestors'] + fields = ['id', 'title', 'slug', 'is_topic', 'source_type', 'sect_type', 'description', 'ancestors'] def get_ancestors(self, obj): if not obj: @@ -189,9 +189,10 @@ class SimpleCategory(serializers.ModelSerializer): "id": a.id, "title": get_localized_text(a.title, request=request) or a.slug, "slug": a.slug, - "source_type": a.source_type, + "is_topic": getattr(a, "is_topic", False), + "source_type": a.source_type if getattr(a, "is_topic", False) else None, "sect_type": a.sect.sect_type if a.sect else None, - "has_hadis": False, + "has_hadis": getattr(a, "is_topic", False), }) return result @@ -237,8 +238,8 @@ class HadisCategorySelectSerializer(serializers.ModelSerializer): class Meta: model = HadisCategory - fields = ['id', 'title', 'source_type','slug', 'sect_id', - 'sect_type','description','children_count','has_hadis','hadis_count', 'has_parent', 'share_link', 'xmind_share_link'] + fields = ['id', 'title', 'is_topic', 'source_type', 'slug', 'sect_id', + 'sect_type', 'description', 'children_count', 'has_hadis', 'hadis_count', 'has_parent', 'share_link', 'xmind_share_link'] def get_has_parent(self, obj): return obj.parent_id is not None @@ -283,7 +284,7 @@ class HadisCategorySelectSourceSerializer(serializers.ModelSerializer): class Meta: model = HadisCategory - fields = ['id', 'title', 'source_type','slug', 'sect_id', + fields = ['id', 'title', 'is_topic', 'source_type','slug', 'sect_id', 'sect_type','description','children_count','has_hadis','hadis_count', 'has_parent', 'share_link', 'xmind_share_link'] def get_has_parent(self, obj): @@ -323,7 +324,7 @@ class CategorySerializer(serializers.ModelSerializer): class Meta: model = HadisCategory - fields = ['id', 'title', 'sect_id', 'sect_type','source_type', + fields = ['id', 'title', 'sect_id', 'sect_type', 'is_topic', 'source_type', 'description','slug', 'children_count','has_hadis','hadis_count', 'has_parent', 'share_link', 'xmind_share_link'] diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index 55f7033..026b60a 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/apps/hadis/serializers/serializers_admin.py @@ -2,6 +2,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, Q +from django.core.exceptions import ValidationError as DjangoValidationError from utils.image_compression import maybe_compress_uploaded_file from utils import absolute_https_url @@ -96,6 +97,7 @@ class AdminHadisCategorySerializer(serializers.ModelSerializer): "id": a.id, "title": a.title, "slug": a.slug, + "is_topic": a.is_topic, "source_type": a.source_type, "sect_type": a.sect.sect_type if a.sect else None, "order": a.order, @@ -112,6 +114,7 @@ class AdminHadisCategorySerializer(serializers.ModelSerializer): "parent", "title", "slug", + "is_topic", "source_type", "order", "sect", @@ -1654,6 +1657,7 @@ class AdminHadisCategoryArchiveSerializer(serializers.ModelSerializer): "parent_detail", "sect", "sect_detail", + "is_topic", "source_type", "title", "description", @@ -1670,8 +1674,24 @@ class AdminHadisCategoryArchiveSerializer(serializers.ModelSerializer): read_only_fields = ["id", "share_link", "xmind_share_link"] extra_kwargs = { "description": {"required": False}, + "source_type": {"required": False, "allow_null": True, "allow_blank": True}, } + def validate(self, attrs): + is_topic = attrs.get('is_topic', getattr(self.instance, 'is_topic', False)) + source_type = attrs.get('source_type', getattr(self.instance, 'source_type', None)) + if is_topic: + if not source_type: + raise serializers.ValidationError({"source_type": _("A topic must have a source type.")}) + else: + attrs['source_type'] = None + + parent = attrs.get('parent', getattr(self.instance, 'parent', None)) + if parent and getattr(parent, 'is_topic', False): + raise serializers.ValidationError({"parent": _("Cannot set parent to a topic. Parent must be a category.")}) + + return super().validate(attrs) + def to_internal_value(self, data): import json data = safe_copy_data(data) @@ -1684,9 +1704,23 @@ class AdminHadisCategoryArchiveSerializer(serializers.ModelSerializer): pass return super().to_internal_value(data) + @staticmethod + def _raise_drf_validation_error(exc): + if hasattr(exc, "message_dict"): + detail = { + ("non_field_errors" if key == "__all__" else key): value + for key, value in exc.message_dict.items() + } + else: + detail = {"non_field_errors": exc.messages} + raise serializers.ValidationError(detail) + def create(self, validated_data): validated_data.pop("remove_xmind_file", False) - return super().create(validated_data) + try: + return super().create(validated_data) + except DjangoValidationError as exc: + self._raise_drf_validation_error(exc) class AdminHadisCategoryArchiveDetailSerializer(AdminHadisCategoryArchiveSerializer): @@ -1705,6 +1739,7 @@ class AdminHadisCategoryArchiveDetailSerializer(AdminHadisCategoryArchiveSeriali "id": a.id, "title": a.title, "slug": a.slug, + "is_topic": a.is_topic, "source_type": a.source_type, "sect_type": a.sect.sect_type if a.sect else None, "order": a.order, @@ -1746,6 +1781,7 @@ class AdminHadisCategoryArchiveDetailSerializer(AdminHadisCategoryArchiveSeriali "title": c.title, "slug": c.slug, "order": c.order, + "is_topic": c.is_topic, "source_type": c.source_type, "has_children": bool(c.annotated_has_children), "has_hadith": bool(c.annotated_has_hadith), @@ -1799,7 +1835,10 @@ class AdminHadisCategoryArchiveDetailSerializer(AdminHadisCategoryArchiveSeriali if remove_xmind_file and instance.xmind_file: instance.xmind_file.delete(save=False) instance.xmind_file = None - return super().update(instance, validated_data) + try: + return super().update(instance, validated_data) + except DjangoValidationError as exc: + self._raise_drf_validation_error(exc) class AdminTransmitterReliabilitySerializer(serializers.ModelSerializer): class Meta: diff --git a/apps/hadis/views/category.py b/apps/hadis/views/category.py index 89d138f..86fc8c2 100644 --- a/apps/hadis/views/category.py +++ b/apps/hadis/views/category.py @@ -235,7 +235,8 @@ class HadisCategoryTreeView(ListAPIView): 'all_titles': all_titles, 'search_text': search_blob, 'slug': category.slug, - 'source_type': category.source_type, + 'is_topic': category.is_topic, + 'source_type': category.source_type if category.is_topic else None, 'sect_id': category.sect_id, 'sect_type': category.sect.sect_type, 'hadis_count': category.direct_hadis_count, diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index daac840..eff2fb4 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -115,9 +115,9 @@ class AdminHadisCategoryViewSet(ReadOnlyModelViewSet): is_topic = self.request.query_params.get("is_topic") if is_topic is not None and is_topic != "all": if str(is_topic).lower() in ["true", "1"]: - queryset = queryset.filter(annotated_has_children=False) + queryset = queryset.filter(is_topic=True) elif str(is_topic).lower() in ["false", "0"]: - queryset = queryset.filter(annotated_has_children=True) + queryset = queryset.filter(is_topic=False) return queryset.order_by("order", "id") @@ -197,9 +197,9 @@ class DovodiHadisCategoryViewSet(ModelViewSet): is_topic = self.request.query_params.get("is_topic") if is_topic is not None and is_topic != "all": if str(is_topic).lower() in ["true", "1"]: - queryset = queryset.filter(annotated_has_children=False) + queryset = queryset.filter(is_topic=True) elif str(is_topic).lower() in ["false", "0"]: - queryset = queryset.filter(annotated_has_children=True) + queryset = queryset.filter(is_topic=False) return queryset.order_by("-id")