Browse Source

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
master
Mohsen Taba 2 weeks ago
parent
commit
bb5c57d0b7
  1. 34
      apps/hadis/migrations/0051_add_category_is_topic.py
  2. 109
      apps/hadis/models/category.py
  3. 17
      apps/hadis/models/hadis.py
  4. 15
      apps/hadis/serializers/category.py
  5. 43
      apps/hadis/serializers/serializers_admin.py
  6. 3
      apps/hadis/views/category.py
  7. 8
      apps/hadis/views_admin.py

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

109
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):

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

15
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']

43
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:

3
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,

8
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")

Loading…
Cancel
Save