Browse Source

feat(hadis): update xmind transmission tree endpoint and safe chain seeding

master
Mohsen Taba 3 weeks ago
parent
commit
094d6f12e0
  1. 52
      apps/hadis/management/commands/fix_hadis_transmitter_layers.py
  2. 38
      apps/hadis/management/commands/seed_hadis_chains.py
  3. 61
      apps/hadis/models/transmitter.py
  4. 247
      apps/hadis/views/category.py

52
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!"))

38
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.' help = 'Seeds random transmission chains (HadisTransmitter) for all existing Hadiths.'
def handle(self, *args, **options): 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()) narrators = list(Transmitters.objects.all())
layers = list(NarratorLayer.objects.all()) layers = list(NarratorLayer.objects.all())
reliabilities = list(TransmitterReliability.objects.all()) reliabilities = list(TransmitterReliability.objects.all())
# بررسی وجود دیتای کافی # بررسی وجود دیتای کافی
if not hadiths: 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 return
if len(narrators) < 4: if len(narrators) < 4:
self.stdout.write(self.style.ERROR("Not enough Transmitters found. Please add at least 4 narrators.")) self.stdout.write(self.style.ERROR("Not enough Transmitters found. Please add at least 4 narrators."))
return 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 = [] links_to_create = []
# ساخت زنجیره‌ها # ساخت زنجیره‌ها
for hadis in hadiths: 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) داشته باشد # هر حدیث بین 2 تا 3 لایه/زنجیره (Chain) داشته باشد
num_chains = random.randint(2, 3) num_chains = random.randint(2, 3)
@ -45,9 +63,7 @@ class Command(BaseCommand):
chain_narrators = random.sample(narrators, num_narrators) chain_narrators = random.sample(narrators, num_narrators)
for order_idx, narrator in enumerate(chain_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 random_status = random.choice(reliabilities) if reliabilities else None
link = HadisTransmitter( link = HadisTransmitter(
@ -55,7 +71,7 @@ class Command(BaseCommand):
transmitter=narrator, transmitter=narrator,
chain_index=chain_idx, chain_index=chain_idx,
order=order_idx, # ترتیب قرارگیری راوی در این زنجیره order=order_idx, # ترتیب قرارگیری راوی در این زنجیره
narrator_layer=random_layer,
narrator_layer=assigned_layer,
status=random_status, status=random_status,
is_gap=random.choice([True, False, False, False]) # احتمال کم برای گپ (انقطاع سند) is_gap=random.choice([True, False, False, False]) # احتمال کم برای گپ (انقطاع سند)
) )
@ -70,6 +86,6 @@ class Command(BaseCommand):
self.stdout.write( self.stdout.write(
self.style.SUCCESS( 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!"
) )
) )

61
apps/hadis/models/transmitter.py

@ -78,57 +78,16 @@ class NarratorLayer(LowercaseSlugMixin, models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): 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) super().save(*args, **kwargs)
class TransmitterReliability(ColorPaletteMixin, LowercaseSlugMixin, models.Model): class TransmitterReliability(ColorPaletteMixin, LowercaseSlugMixin, models.Model):

247
apps/hadis/views/category.py

@ -2,11 +2,11 @@ from rest_framework.generics import ListAPIView
from rest_framework.response import Response from rest_framework.response import Response
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from utils.pagination import NoPagination from utils.pagination import NoPagination
from django.db.models import Q
from django.db.models import Q, Prefetch
from collections import defaultdict from collections import defaultdict
from utils.pagination import StandardResultsSetPagination from utils.pagination import StandardResultsSetPagination
from utils import absolute_https_url 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 apps.bookmark.serializers.bookmark import BookmarkStatusSerializer
from ..serializers import ( from ..serializers import (
HadisCategorySectListSerializer, HadisCategorySectListSerializer,
@ -15,6 +15,7 @@ from ..serializers import (
HadisCategorySelectSerializer , HadisCategorySelectSerializer ,
HadisCategorySelectSourceSerializer, HadisCategorySelectSourceSerializer,
get_localized_text, get_localized_text,
get_arabic_localized_text,
SimpleCategory SimpleCategory
) )
from ..docs import ( from ..docs import (
@ -506,93 +507,241 @@ def debug_headers(request):
from rest_framework.views import APIView from rest_framework.views import APIView
class HadisCategoryXMindView(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 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): if not json_field or not isinstance(json_field, list):
return "Unknown"
# 1. Try specific language
return ""
for item in json_field: 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', '') return item.get('text', '')
# 2. Fallback to English
for item in json_field: 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', '') 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 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 @hadis_category_xmind_swagger
def get(self, request, category_slug): 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) 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( hadiths = Hadis.objects.filter(
category=category, category=category,
status=True 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: 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( bookmark_info = BookmarkStatusSerializer.get_bookmark_info(
obj=hadis, obj=hadis,
user=user, user=user,
service='hadith' service='hadith'
) )
is_bookmarked = bookmark_info.get('is_bookmarked', False) 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, "slug": hadis.slug,
"number": hadis.number,
"title": hadis_title, "title": hadis_title,
"title_narrator": hadis_title_narrator, "title_narrator": hadis_title_narrator,
"text": hadis.text, "text": hadis.text,
"translation": hadis_translation, "translation": hadis_translation,
"share_link": hadis.share_link, "share_link": hadis.share_link,
"is_bookmarked": is_bookmarked, "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 = { data = {
"rootTopic": { "rootTopic": {
"id": f"cat-{category.id}",
"id": root_tree["id"],
"node_type": "category",
"slug": category.slug,
"title": root_title, "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": { "children": {
"attached": children_nodes
"attached": root_attached
} }
} }
} }

Loading…
Cancel
Save