diff --git a/apps/hadis/management/commands/fix_hadis_transmitter_layers.py b/apps/hadis/management/commands/fix_hadis_transmitter_layers.py new file mode 100644 index 0000000..42b19fb --- /dev/null +++ b/apps/hadis/management/commands/fix_hadis_transmitter_layers.py @@ -0,0 +1,52 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from django.db.models import F +from apps.hadis.models import Hadis, HadisTransmitter, NarratorLayer + +class Command(BaseCommand): + help = "Fast chunked fix for HadisTransmitter narrator_layer foreign keys." + + def handle(self, *args, **options): + self.stdout.write("Finding unique Hadiths with mismatched transmitter layers...") + + mismatched_hadis_ids = list(set( + HadisTransmitter.objects.filter(narrator_layer__isnull=False) + .exclude(narrator_layer__hadis=F('hadis')) + .values_list('hadis_id', flat=True) + )) + + total = len(mismatched_hadis_ids) + self.stdout.write(f"Found {total} Hadiths to fix. Processing in chunks of 50...") + + chunk_size = 50 + for i in range(0, total, chunk_size): + chunk = mismatched_hadis_ids[i:i + chunk_size] + with transaction.atomic(): + for hadis_id in chunk: + hadis = Hadis.objects.get(id=hadis_id) + layers = list(hadis.narrator_layers.all().order_by('number')) + + if not layers: + l1 = NarratorLayer.objects.create( + hadis=hadis, + number=1, + name=[{"language_code": "en", "text": "Primary Transmitters"}], + description=[{"language_code": "en", "text": "Primary transmitters layer"}] + ) + l2 = NarratorLayer.objects.create( + hadis=hadis, + number=2, + name=[{"language_code": "en", "text": "Secondary Transmitters"}], + description=[{"language_code": "en", "text": "Secondary transmitters layer"}] + ) + layers = [l1, l2] + + if len(layers) == 1: + HadisTransmitter.objects.filter(hadis_id=hadis_id).update(narrator_layer=layers[0]) + else: + HadisTransmitter.objects.filter(hadis_id=hadis_id, order__lte=1).update(narrator_layer=layers[0]) + HadisTransmitter.objects.filter(hadis_id=hadis_id, order__gt=1).update(narrator_layer=layers[1]) + + self.stdout.write(f"Fixed {min(i + chunk_size, total)}/{total} Hadiths...") + + self.stdout.write(self.style.SUCCESS("Done! All Hadiths transmitter layers successfully fixed!")) diff --git a/apps/hadis/management/commands/seed_hadis_chains.py b/apps/hadis/management/commands/seed_hadis_chains.py index 814068b..df7c2bd 100644 --- a/apps/hadis/management/commands/seed_hadis_chains.py +++ b/apps/hadis/management/commands/seed_hadis_chains.py @@ -9,31 +9,49 @@ class Command(BaseCommand): help = 'Seeds random transmission chains (HadisTransmitter) for all existing Hadiths.' def handle(self, *args, **options): - self.stdout.write(self.style.WARNING("Starting to seed random transmission chains...")) + self.stdout.write(self.style.WARNING("Checking Hadiths without transmission chains...")) # دریافت تمام دیتاهای پایه - hadiths = list(Hadis.objects.all()) + hadiths = list(Hadis.objects.filter(transmitters__isnull=True).distinct()) narrators = list(Transmitters.objects.all()) layers = list(NarratorLayer.objects.all()) reliabilities = list(TransmitterReliability.objects.all()) # بررسی وجود دیتای کافی if not hadiths: - self.stdout.write(self.style.ERROR("No Hadiths found. Please seed Hadiths first.")) + self.stdout.write(self.style.SUCCESS("All Hadiths already have transmission chains! Nothing to seed.")) return if len(narrators) < 4: self.stdout.write(self.style.ERROR("Not enough Transmitters found. Please add at least 4 narrators.")) return - # پاک کردن تمام زنجیره‌های روایی قبلی برای جلوگیری از خطای Unique Constraint - self.stdout.write("Clearing old transmission chains...") - HadisTransmitter.objects.all().delete() + self.stdout.write(f"Found {len(hadiths)} Hadiths without transmitters. Generating chains...") links_to_create = [] # ساخت زنجیره‌ها for hadis in hadiths: + hadis_layers = list(hadis.narrator_layers.all().order_by('number')) + if not hadis_layers: + layer1 = NarratorLayer.objects.create( + hadis=hadis, + number=1, + name=[{"language_code": "en", "text": "Primary Transmitters"}], + description=[{"language_code": "en", "text": "Primary layer of transmitters."}], + slug=f"hadis-{hadis.id}-l1-primary" + ) + layer2 = NarratorLayer.objects.create( + hadis=hadis, + number=2, + name=[{"language_code": "en", "text": "Secondary Transmitters"}], + description=[{"language_code": "en", "text": "Secondary layer of transmitters."}], + slug=f"hadis-{hadis.id}-l2-secondary" + ) + hadis_layers = [layer1, layer2] + + num_layers = len(hadis_layers) + # هر حدیث بین 2 تا 3 لایه/زنجیره (Chain) داشته باشد num_chains = random.randint(2, 3) @@ -45,9 +63,7 @@ class Command(BaseCommand): chain_narrators = random.sample(narrators, num_narrators) for order_idx, narrator in enumerate(chain_narrators): - - # انتخاب رندوم لایه و وضعیت (اگر در دیتابیس وجود داشته باشند) - random_layer = random.choice(layers) if layers else None + assigned_layer = hadis_layers[min(num_layers - 1, order_idx // 2)] if num_layers > 1 else hadis_layers[0] random_status = random.choice(reliabilities) if reliabilities else None link = HadisTransmitter( @@ -55,7 +71,7 @@ class Command(BaseCommand): transmitter=narrator, chain_index=chain_idx, order=order_idx, # ترتیب قرارگیری راوی در این زنجیره - narrator_layer=random_layer, + narrator_layer=assigned_layer, status=random_status, is_gap=random.choice([True, False, False, False]) # احتمال کم برای گپ (انقطاع سند) ) @@ -70,6 +86,6 @@ class Command(BaseCommand): self.stdout.write( self.style.SUCCESS( - f"\n🎉 Successfully created {len(links_to_create)} random narrator links across {len(hadiths)} Hadiths!" + f"Successfully created {len(links_to_create)} random narrator links across {len(hadiths)} Hadiths!" ) ) \ No newline at end of file diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index 9ecb73c..cdc1112 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -78,57 +78,16 @@ class NarratorLayer(LowercaseSlugMixin, models.Model): def save(self, *args, **kwargs): if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): - # Try to get text from name field with robust error handling - try: - if self.name and isinstance(self.name, list) and len(self.name) > 0: - first_item = self.name[0] - if isinstance(first_item, dict): - text = first_item.get('text', '').strip() - if text: - slug = slugify(text) - # Ensure uniqueness - counter = 1 - base_slug = slug - while NarratorLayer.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - else: - # Fallback to layer number if text is empty - base_slug = f"layer-{self.number}" - slug = base_slug - counter = 1 - while NarratorLayer.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - else: - # Fallback to layer number if name structure is invalid - base_slug = f"layer-{self.number}" - slug = base_slug - counter = 1 - while NarratorLayer.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - else: - # Fallback to layer number if name structure is invalid - base_slug = f"layer-{self.number}" - slug = base_slug - counter = 1 - while NarratorLayer.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - except (IndexError, KeyError, AttributeError, TypeError): - # Fallback to layer number on any error - base_slug = f"layer-{self.number}" - slug = base_slug - counter = 1 - while NarratorLayer.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug + import uuid + hadis_prefix = f"hadis-{self.hadis_id}-" if self.hadis_id else "" + base_text = "" + if self.name and isinstance(self.name, list) and len(self.name) > 0: + first_item = self.name[0] + if isinstance(first_item, dict): + base_text = first_item.get('text', '').strip() + + slug_base = slugify(base_text) if base_text else f"layer-{self.number}" + self.slug = f"{hadis_prefix}l{self.number}-{slug_base}-{uuid.uuid4().hex[:6]}" super().save(*args, **kwargs) class TransmitterReliability(ColorPaletteMixin, LowercaseSlugMixin, models.Model): diff --git a/apps/hadis/views/category.py b/apps/hadis/views/category.py index 2c38fb1..b9d6988 100644 --- a/apps/hadis/views/category.py +++ b/apps/hadis/views/category.py @@ -2,11 +2,11 @@ from rest_framework.generics import ListAPIView from rest_framework.response import Response from django.shortcuts import get_object_or_404 from utils.pagination import NoPagination -from django.db.models import Q +from django.db.models import Q, Prefetch from collections import defaultdict from utils.pagination import StandardResultsSetPagination from utils import absolute_https_url -from ..models import HadisSect, HadisCategory,Hadis +from ..models import HadisSect, HadisCategory, Hadis, HadisTransmitter from apps.bookmark.serializers.bookmark import BookmarkStatusSerializer from ..serializers import ( HadisCategorySectListSerializer, @@ -15,6 +15,7 @@ from ..serializers import ( HadisCategorySelectSerializer , HadisCategorySelectSourceSerializer, get_localized_text, + get_arabic_localized_text, SimpleCategory ) from ..docs import ( @@ -506,93 +507,241 @@ def debug_headers(request): from rest_framework.views import APIView + class HadisCategoryXMindView(APIView): """ - Returns a mind-map JSON structure for a specific category and its hadiths. + Returns a mind-map JSON structure for a specific category and its transmission chains ending in hadiths. Root -> Category - Children -> Hadiths + Level 1+ -> Narrators in transmission chains (merged prefixes) + Leaves -> Hadiths """ - def get_localized_text(self, json_field, lang): - """Helper to extract text from your JSON structure""" + def get_localized(self, json_field, lang): if not json_field or not isinstance(json_field, list): - return "Unknown" - - # 1. Try specific language + return "" for item in json_field: - if item.get('language_code') == lang: + if isinstance(item, dict) and item.get('language_code') == lang: return item.get('text', '') - - # 2. Fallback to English for item in json_field: - if item.get('language_code') == 'en': + if isinstance(item, dict) and item.get('language_code') == 'en': return item.get('text', '') - - # 3. Fallback to first available - if len(json_field) > 0: + if len(json_field) > 0 and isinstance(json_field[0], dict): return json_field[0].get('text', '') - - return "Unknown" + return "" + + def get_arabic(self, json_field): + return get_arabic_localized_text(json_field) or "" + + def _get_thumbnail_url(self, thumbnail, request): + if not thumbnail: + return None + try: + if request: + return request.build_absolute_uri(thumbnail.url) + except Exception: + pass + return thumbnail.url @hadis_category_xmind_swagger def get(self, request, category_slug): - # 1. Determine Language (support ?lang=ru or Accept-Language header) - lang = request.query_params.get('lang','en') - - # 2. Get the Category (Root Node) + lang = request.query_params.get('lang', 'en') category = get_object_or_404(HadisCategory, slug=category_slug) - root_title = self.get_localized_text(category.title, lang) + root_title = self.get_localized(category.title, lang) or category.slug + + user = request.user if request and hasattr(request, 'user') else None + if user and user.is_anonymous: + user = None + + # Fetch active hadiths with their transmitters and corrections + transmitter_qs = HadisTransmitter.objects.select_related( + 'transmitter__reliability', + 'status' + ).order_by('chain_index', 'order') - # 3. Get the Hadiths (Child Nodes) - # Note: Mind maps generally show ALL nodes, so we avoid pagination here. - # If you have 1000+ hadiths, consider limiting this query (e.g., [:50]). hadiths = Hadis.objects.filter( category=category, status=True - ).order_by('number').only('id', 'number', 'title', 'title_narrator', 'translation', 'text', 'slug', 'share_link') + ).order_by('number').prefetch_related( + Prefetch('transmitters', queryset=transmitter_qs), + 'hadiscorrection_set' + ) - # 4. Get user for bookmark check - user = request.user if request and hasattr(request, 'user') else None - if user and user.is_anonymous: - user = None + # Build prefix tree (Trie-like structure) + # Root: Category + # Branches: Narrator nodes along transmission chains + # Leaves: Hadiths + root_tree = { + "id": f"cat-{category.id}", + "slug": category.slug, + "node_type": "category", + "title": root_title, + "children_map": {}, # key: (node_type, id) -> subtree dict + "leaf_hadiths": set() + } - # 5. Build Child Nodes List - children_nodes = [] for hadis in hadiths: - # Get Title - hadis_title = self.get_localized_text(hadis.title, lang) - hadis_title_narrator = self.get_localized_text(hadis.title_narrator, lang) - hadis_translation = self.get_localized_text(hadis.translation, lang) - - # Get bookmark status + hadis_title = self.get_localized(hadis.title, lang) + hadis_title_narrator = self.get_localized(hadis.title_narrator, lang) + hadis_translation = self.get_localized(hadis.translation, lang) + bookmark_info = BookmarkStatusSerializer.get_bookmark_info( obj=hadis, user=user, service='hadith' ) is_bookmarked = bookmark_info.get('is_bookmarked', False) - - children_nodes.append({ - "id": hadis.id, + + corrections_data = [ + { + "id": c.id, + "slug": c.slug, + "title": self.get_localized(c.title, lang), + "narrator": c.narrator, + "text": c.text, + "translation": self.get_localized(c.translation, lang), + "share_link": c.share_link, + } + for c in hadis.hadiscorrection_set.all() + ] + + hadis_node_data = { + "id": f"hadis-{hadis.id}", + "hadis_id": hadis.id, + "node_type": "hadith", "slug": hadis.slug, + "number": hadis.number, "title": hadis_title, "title_narrator": hadis_title_narrator, "text": hadis.text, "translation": hadis_translation, "share_link": hadis.share_link, "is_bookmarked": is_bookmarked, - # Optional: Add 'href' if you want XMind to handle links, - # but usually the frontend handles the 'click' event based on ID. - }) + "corrections": corrections_data, + } + + # Group transmitters by chain_index + chain_groups = defaultdict(list) + for ht in hadis.transmitters.all(): + if ht.transmitter: + chain_groups[ht.chain_index].append(ht) + + if not chain_groups: + # No transmitters: directly attach hadith to category root + hadis_key = ("hadith", hadis.id) + if hadis_key not in root_tree["children_map"]: + root_tree["children_map"][hadis_key] = { + "data": hadis_node_data, + "children_map": {}, + "leaf_hadiths": set() + } + root_tree["children_map"][hadis_key]["leaf_hadiths"].add(hadis.id) + root_tree["leaf_hadiths"].add(hadis.id) + else: + for chain_idx, ht_list in chain_groups.items(): + ht_list.sort(key=lambda x: x.order) + + curr = root_tree + curr["leaf_hadiths"].add(hadis.id) + + for ht in ht_list: + tr = ht.transmitter + tr_key = ("narrator", tr.id) + + if tr_key not in curr["children_map"]: + rel_obj = tr.reliability or ht.status + rel_data = None + if rel_obj: + rel_data = { + "id": rel_obj.id, + "title": self.get_localized(rel_obj.title, lang), + "color": getattr(rel_obj, 'color', None), + "main_color_code": getattr(rel_obj, 'main_color_code', None), + "slug": getattr(rel_obj, 'slug', None), + } + + tr_node_data = { + "id": f"narrator-{tr.id}", + "transmitter_id": tr.id, + "node_type": "narrator", + "slug": tr.slug, + "title": self.get_localized(tr.full_name, lang) or tr.slug, + "full_name": self.get_localized(tr.full_name, lang), + "arabic_full_name": self.get_arabic(tr.full_name), + "kunya": self.get_localized(tr.kunya, lang), + "arabic_kunya": self.get_arabic(tr.kunya), + "known_as": self.get_localized(tr.known_as, lang), + "arabic_known_as": self.get_arabic(tr.known_as), + "nickname": self.get_localized(tr.nickname, lang), + "arabic_nickname": self.get_arabic(tr.nickname), + "birth_year_hijri": tr.birth_year_hijri, + "death_year_hijri": tr.death_year_hijri, + "age_at_death": tr.age_at_death, + "generation": tr.generation, + "tadlis": tr.tadlis, + "ikhtilat": tr.ikhtilat, + "companion_type": tr.companion_type, + "madhhab": tr.madhhab, + "in_sahih_muslim": tr.in_sahih_muslim, + "in_sahih_bukhari": tr.in_sahih_bukhari, + "reliability": rel_data, + "thumbnail": self._get_thumbnail_url(tr.thumbnail, request), + "share_link": tr.share_link, + } + curr["children_map"][tr_key] = { + "data": tr_node_data, + "children_map": {}, + "leaf_hadiths": set() + } + curr = curr["children_map"][tr_key] + curr["leaf_hadiths"].add(hadis.id) + + # Attach hadith leaf under the last narrator of this chain + hadis_key = ("hadith", hadis.id) + if hadis_key not in curr["children_map"]: + curr["children_map"][hadis_key] = { + "data": hadis_node_data, + "children_map": {}, + "leaf_hadiths": set() + } + curr["children_map"][hadis_key]["leaf_hadiths"].add(hadis.id) + + # Convert internal trie to the standard XMind JSON tree structure + def format_tree_node(node_dict, path_prefix=""): + node_data = dict(node_dict["data"]) if "data" in node_dict else {} + children_map = node_dict.get("children_map", {}) + + # Form unique instance ID for tree canvas + base_id = node_data.get("id", "node") + unique_node_id = f"{path_prefix}_{base_id}" if path_prefix else base_id + node_data["node_id"] = unique_node_id + + attached = [] + for (child_type, child_id), child_subtree in children_map.items(): + formatted_child = format_tree_node(child_subtree, path_prefix=unique_node_id) + attached.append(formatted_child) + + node_data["count"] = len(attached) + node_data["leaf_count"] = len(node_dict.get("leaf_hadiths", set())) + node_data["children"] = { + "attached": attached + } + return node_data + + root_attached = [] + for (child_type, child_id), child_subtree in root_tree["children_map"].items(): + root_attached.append(format_tree_node(child_subtree, path_prefix="root")) - # 6. Construct XMind JSON Structure data = { "rootTopic": { - "id": f"cat-{category.id}", + "id": root_tree["id"], + "node_type": "category", + "slug": category.slug, "title": root_title, - "structureClass": "org.xmind.ui.map.unbalanced", # Standard right-branching map + "count": len(root_attached), + "leaf_count": len(root_tree["leaf_hadiths"]), + "structureClass": "org.xmind.ui.map.unbalanced", "children": { - "attached": children_nodes + "attached": root_attached } } }