3 Commits

Author SHA1 Message Date
Mohsen Taba 73cf49958a feat(hadis): return raw translations in sync api and invalidate cache on version update 1 month ago
Mohsen Taba 32d4e58555 feat(hadis): enhance xmind category tree api and narrator order 1 month ago
Mohsen Taba 68818d7c2f feat(hadis): enhance category corrections search and request localization 1 month ago
  1. 6
      apps/hadis/apps.py
  2. 7
      apps/hadis/docs.py
  3. 19
      apps/hadis/serializers/category.py
  4. 7
      apps/hadis/serializers/hadis.py
  5. 77
      apps/hadis/signals.py
  6. 2
      apps/hadis/urls.py
  7. 189
      apps/hadis/views/category.py
  8. 10
      apps/hadis/views/hadis.py
  9. 19
      apps/hadis/views_admin.py

6
apps/hadis/apps.py

@ -4,6 +4,6 @@ from django.apps import AppConfig
class HadisConfig(AppConfig): class HadisConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.hadis' name = 'apps.hadis'
# def ready(self):
# # Import the signals module when the app starts
# # import apps.hadis.signals
def ready(self):
# Import the signals module when the app starts
import apps.hadis.signals

7
apps/hadis/docs.py

@ -249,7 +249,12 @@ hadis_sync_swagger = swagger_auto_schema(
"title": "The Merit of Seeking Knowledge", "title": "The Merit of Seeking Knowledge",
"title_narrator": "Jabir bin Abdullah", "title_narrator": "Jabir bin Abdullah",
"text": "اتَّقِ اللَّهَ حَيْثُمَا كُنْتَ، وَأَتْبِعِ السَّيِّئَةَ الْحَسَنَةَ تَمْحُهَا.", "text": "اتَّقِ اللَّهَ حَيْثُمَا كُنْتَ، وَأَتْبِعِ السَّيِّئَةَ الْحَسَنَةَ تَمْحُهَا.",
"translation": "The best among you are those who have the best manners and character.",
"translation": [
{
"language_code": "en",
"text": "The best among you are those who have the best manners and character."
}
],
"detail": { "detail": {
"address": [ "address": [
"Sahih al-Bukhari", "Sahih al-Bukhari",

19
apps/hadis/serializers/category.py

@ -96,11 +96,24 @@ class LocalizedField(serializers.Field):
if not isinstance(value, list): if not isinstance(value, list):
return _get_localized_item_value(value) return _get_localized_item_value(value)
# Get language from request, then fall back to global language / 'fa'
# Get language from request, then fall back to global language / 'en'
request = self.context.get("request") request = self.context.get("request")
language_code = getattr(request, "LANGUAGE_CODE", None) if request else None
language_code = None
if request:
query_params = getattr(request, "query_params", None) or getattr(request, "GET", None)
if query_params:
language_code = query_params.get("language_code") or query_params.get("lang")
if not language_code:
language_code = getattr(request, "LANGUAGE_CODE", None)
if not language_code and hasattr(request, "headers"):
language_code = request.headers.get("Accept-Language")
elif not language_code and hasattr(request, "META"):
language_code = request.META.get("HTTP_ACCEPT_LANGUAGE")
if language_code:
language_code = language_code.split(",")[0].split(";")[0].split("-")[0].strip().lower()
if not language_code: if not language_code:
language_code = get_language() or "fa" # global active language
language_code = get_language() or "en"
# 1) Exact match with request language # 1) Exact match with request language
for item in value: for item in value:

7
apps/hadis/serializers/hadis.py

@ -90,9 +90,9 @@ class HadisSyncSerializer(serializers.ModelSerializer):
corrections = serializers.SerializerMethodField() corrections = serializers.SerializerMethodField()
category_slug = serializers.CharField(source='category.slug', read_only=True) category_slug = serializers.CharField(source='category.slug', read_only=True)
interpretations = serializers.SerializerMethodField() interpretations = serializers.SerializerMethodField()
title =LocalizedField()
title = LocalizedField()
title_narrator = LocalizedField() title_narrator = LocalizedField()
translation = LocalizedField()
translation = serializers.SerializerMethodField()
class Meta: class Meta:
model = Hadis model = Hadis
@ -103,6 +103,9 @@ class HadisSyncSerializer(serializers.ModelSerializer):
'detail', 'narrators', 'explanations', 'corrections', 'interpretations' 'detail', 'narrators', 'explanations', 'corrections', 'interpretations'
] ]
def get_translation(self, obj):
return obj.translation or []
def get_detail(self, obj): def get_detail(self, obj):
request = self.context.get('request') request = self.context.get('request')

77
apps/hadis/signals.py

@ -1,33 +1,44 @@
# hadith_app/signals.py
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache
from .models import *
# 1. Define all models that affect the list
# If a Category title changes, the list must update.
# If a Status changes, the list must update.
TARGET_MODELS = [Hadis , HadisCategory , HadisCollection , HadisCorrection , HadisInCollection , HadisReference ,
HadisSect , HadisStatus , HadisTag , HadisTransmitter , Transmitters ,TransmitterOpinion , TransmitterReliability,
TransmitterOriginalText,ReferenceImage , BookReference , BookAttribute, BookAuthor ,BookReferenceImage,
NarratorLayer , OpinionStatus ]
@receiver(post_save)
@receiver(post_delete)
def clear_hadis_cache(sender, instance, **kwargs):
"""
Clears the API cache whenever a Hadith or related model is saved/deleted.
"""
if sender in TARGET_MODELS:
# This is the magic command from django-redis
# It finds ALL keys starting with the prefix and deletes them
# *:1: is the default django version prefix
try:
# Delete any key that contains our prefix "hadis_api"
# The pattern "*hadis_api*" ensures we catch all variations (headers, pages, etc)
cache.delete_pattern("*hadis_api*")
print(f"Cache cleared for {sender.__name__} update!")
except Exception as e:
# Fail silently or log error, don't crash the save transaction
print(f"Cache clear failed: {e}")
# hadith_app/signals.py
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache
from .models import *
# 1. Define all models that affect the list
# If a Category title changes, the list must update.
# If a Status changes, the list must update.
TARGET_MODELS = [
Hadis, HadisCategory, HadisCollection, HadisCorrection, HadisInCollection, HadisReference,
HadisSect, HadisStatus, HadisTag, HadisTransmitter, Transmitters, TransmitterOpinion, TransmitterReliability,
TransmitterOriginalText, ReferenceImage, BookReference, BookAttribute, BookAuthor, BookReferenceImage,
NarratorLayer, OpinionStatus, ContentRelease
]
def invalidate_hadis_cache():
"""
Deletes all Redis cache keys matching '*hadis_api*'.
Falls back to cache.clear() if cache backend doesn't support delete_pattern.
"""
try:
if hasattr(cache, 'delete_pattern'):
deleted = cache.delete_pattern("*hadis_api*")
print(f"Cache cleared for hadis_api (deleted {deleted} keys)!")
else:
cache.clear()
print("Cache cleared for hadis_api (fallback clear)!")
except Exception as e:
# Fail silently or log error, don't crash the transaction
print(f"Cache clear failed: {e}")
@receiver(post_save)
@receiver(post_delete)
def clear_hadis_cache(sender, instance, **kwargs):
"""
Clears the API cache whenever a Hadith or related model is saved/deleted.
"""
if sender in TARGET_MODELS:
invalidate_hadis_cache()
print(f"Cache cleared for {sender.__name__} update!")

2
apps/hadis/urls.py

@ -100,7 +100,7 @@ urlpatterns = [
path('categories/', cached_view(CategoriesView.as_view()), name='categories'), # ← Least specific LAST path('categories/', cached_view(CategoriesView.as_view()), name='categories'), # ← Least specific LAST
# Hadis paths # Hadis paths
path('category/<str:category_slug>/xmind/', cached_view(HadisCategoryXMindView.as_view()), name='hadis-category-xmind'), # ← Must be before other category paths
path('category/<str:category_slug>/xmind/', HadisCategoryXMindView.as_view(), name='hadis-category-xmind'), # ← Must be before other category paths
path('category/<str:category_slug>/corrections/', CategoryHadisCorrectionsView.as_view(), name='category-hadis-corrections'), path('category/<str:category_slug>/corrections/', CategoryHadisCorrectionsView.as_view(), name='category-hadis-corrections'),
path('category/<str:category_slug>/', HadisListView.as_view(), name='hadis-list'), path('category/<str:category_slug>/', HadisListView.as_view(), name='hadis-list'),
path('arguments/filters/', cached_view(HadisFiltersView.as_view()), name='hadis-filters'), path('arguments/filters/', cached_view(HadisFiltersView.as_view()), name='hadis-filters'),

189
apps/hadis/views/category.py

@ -516,18 +516,8 @@ class HadisCategoryXMindView(APIView):
Leaves -> Hadiths Leaves -> Hadiths
""" """
def get_localized(self, json_field, lang):
if not json_field or not isinstance(json_field, list):
return ""
for item in json_field:
if isinstance(item, dict) and item.get('language_code') == lang:
return item.get('text', '')
for item in json_field:
if isinstance(item, dict) and item.get('language_code') == 'en':
return item.get('text', '')
if len(json_field) > 0 and isinstance(json_field[0], dict):
return json_field[0].get('text', '')
return ""
def get_localized(self, json_field, lang='en'):
return get_localized_text(json_field, language_code=lang) or ""
def get_arabic(self, json_field): def get_arabic(self, json_field):
return get_arabic_localized_text(json_field) or "" return get_arabic_localized_text(json_field) or ""
@ -544,7 +534,7 @@ class HadisCategoryXMindView(APIView):
@hadis_category_xmind_swagger @hadis_category_xmind_swagger
def get(self, request, category_slug): def get(self, request, category_slug):
lang = request.query_params.get('lang', 'en')
lang = request.query_params.get('lang') or request.query_params.get('language_code') or 'en'
category = get_object_or_404(HadisCategory, slug=category_slug) category = get_object_or_404(HadisCategory, slug=category_slug)
root_title = self.get_localized(category.title, lang) or category.slug root_title = self.get_localized(category.title, lang) or category.slug
@ -555,14 +545,18 @@ class HadisCategoryXMindView(APIView):
# Fetch active hadiths with their transmitters and corrections # Fetch active hadiths with their transmitters and corrections
transmitter_qs = HadisTransmitter.objects.select_related( transmitter_qs = HadisTransmitter.objects.select_related(
'transmitter__reliability', 'transmitter__reliability',
'status'
).order_by('chain_index', 'order')
'status',
'narrator_layer'
).prefetch_related(
'uncertain_transmitters__reliability'
).order_by('order')
hadiths = Hadis.objects.filter( hadiths = Hadis.objects.filter(
category=category, category=category,
status=True status=True
).order_by('number').prefetch_related( ).order_by('number').prefetch_related(
Prefetch('transmitters', queryset=transmitter_qs), Prefetch('transmitters', queryset=transmitter_qs),
'narrator_layers',
'hadiscorrection_set' 'hadiscorrection_set'
) )
@ -619,13 +613,15 @@ class HadisCategoryXMindView(APIView):
"corrections": corrections_data, "corrections": corrections_data,
} }
# Group transmitters by chain_index
chain_groups = defaultdict(list)
# Group transmitters by narrative layer (NarratorLayer)
# Each narrative layer forms its own transmission branch
layer_groups = defaultdict(list)
for ht in hadis.transmitters.all(): for ht in hadis.transmitters.all():
if ht.transmitter: if ht.transmitter:
chain_groups[ht.chain_index].append(ht)
layer_key = ht.narrator_layer_id if ht.narrator_layer_id is not None else f"chain-{ht.chain_index}"
layer_groups[layer_key].append(ht)
if not chain_groups:
if not layer_groups:
# No transmitters: directly attach hadith to category root # No transmitters: directly attach hadith to category root
hadis_key = ("hadith", hadis.id) hadis_key = ("hadith", hadis.id)
if hadis_key not in root_tree["children_map"]: if hadis_key not in root_tree["children_map"]:
@ -637,18 +633,34 @@ class HadisCategoryXMindView(APIView):
root_tree["children_map"][hadis_key]["leaf_hadiths"].add(hadis.id) root_tree["children_map"][hadis_key]["leaf_hadiths"].add(hadis.id)
root_tree["leaf_hadiths"].add(hadis.id) root_tree["leaf_hadiths"].add(hadis.id)
else: else:
for chain_idx, ht_list in chain_groups.items():
ht_list.sort(key=lambda x: x.order)
# Sort layers by layer number if available
sorted_layer_items = sorted(
layer_groups.items(),
key=lambda item: (item[1][0].narrator_layer.number if item[1] and item[1][0].narrator_layer else 0)
)
for layer_key, ht_list in sorted_layer_items:
# Sort transmitters in reverse order so the chain goes from first narrator to last narrator reaching the hadith
ht_list.sort(key=lambda x: x.order, reverse=True)
curr = root_tree curr = root_tree
curr["leaf_hadiths"].add(hadis.id) curr["leaf_hadiths"].add(hadis.id)
for ht in ht_list: for ht in ht_list:
tr = ht.transmitter tr = ht.transmitter
tr_key = ("narrator", tr.id)
is_uncertain = getattr(ht, 'is_uncertain', False)
cand_objs = list(ht.uncertain_transmitters.all()) if is_uncertain else []
is_node_uncertain = bool(is_uncertain and len(cand_objs) > 0)
if is_node_uncertain:
cand_ids = tuple(sorted([ut.id for ut in cand_objs]))
tr_key = ("uncertain", cand_ids)
else:
cand_ids = ()
tr_key = ("narrator", tr.id if tr else ht.id)
if tr_key not in curr["children_map"]: if tr_key not in curr["children_map"]:
rel_obj = tr.reliability or ht.status
rel_obj = (tr.reliability if tr else None) or ht.status
rel_data = None rel_data = None
if rel_obj: if rel_obj:
rel_data = { rel_data = {
@ -659,33 +671,94 @@ class HadisCategoryXMindView(APIView):
"slug": getattr(rel_obj, 'slug', None), "slug": getattr(rel_obj, 'slug', None),
} }
layer_obj = ht.narrator_layer
layer_data = None
if layer_obj:
layer_data = {
"id": layer_obj.id,
"number": layer_obj.number,
"slug": layer_obj.slug,
"name": self.get_localized(layer_obj.name, lang),
"description": self.get_localized(layer_obj.description, lang),
}
uncertain_candidates = []
if is_node_uncertain:
for ut in cand_objs:
ut_rel = ut.reliability
ut_rel_data = None
if ut_rel:
ut_rel_data = {
"id": ut_rel.id,
"title": self.get_localized(ut_rel.title, lang),
"color": getattr(ut_rel, 'color', None),
"main_color_code": getattr(ut_rel, 'main_color_code', None),
"slug": getattr(ut_rel, 'slug', None),
}
uncertain_candidates.append({
"id": ut.id,
"transmitter_id": ut.id,
"slug": ut.slug,
"name": self.get_localized(ut.full_name, lang) or ut.slug,
"title": self.get_localized(ut.full_name, lang) or ut.slug,
"full_name": self.get_localized(ut.full_name, lang),
"arabic_full_name": self.get_arabic(ut.full_name),
"kunya": self.get_localized(ut.kunya, lang),
"arabic_kunya": self.get_arabic(ut.kunya),
"known_as": self.get_localized(ut.known_as, lang),
"arabic_known_as": self.get_arabic(ut.known_as),
"nickname": self.get_localized(ut.nickname, lang),
"arabic_nickname": self.get_arabic(ut.nickname),
"birth_year_hijri": ut.birth_year_hijri,
"death_year_hijri": ut.death_year_hijri,
"reliability": ut_rel_data,
"thumbnail": self._get_thumbnail_url(ut.thumbnail, request),
"share_link": ut.share_link,
})
node_type_str = "uncertain" if is_node_uncertain else "narrator"
node_id_str = f"uncertain-{'-'.join(map(str, cand_ids))}" if is_node_uncertain else f"narrator-{tr.id if tr else ht.id}"
tr_node_data = { 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,
"id": node_id_str,
"transmitter_id": tr.id if tr else (uncertain_candidates[0]["id"] if uncertain_candidates else None),
"node_type": node_type_str,
"tag": node_type_str,
"slug": tr.slug if tr else (uncertain_candidates[0]["slug"] if uncertain_candidates else None),
"title": "Uncertain narrator" if is_node_uncertain else (self.get_localized(tr.full_name, lang) or tr.slug if tr else "Narrator"),
"full_name": "Uncertain narrator" if is_node_uncertain else (self.get_localized(tr.full_name, lang) if tr else "Narrator"),
"arabic_full_name": self.get_arabic(tr.full_name) if tr else None,
"kunya": self.get_localized(tr.kunya, lang) if tr else None,
"arabic_kunya": self.get_arabic(tr.kunya) if tr else None,
"known_as": self.get_localized(tr.known_as, lang) if tr else None,
"arabic_known_as": self.get_arabic(tr.known_as) if tr else None,
"nickname": self.get_localized(tr.nickname, lang) if tr else None,
"arabic_nickname": self.get_arabic(tr.nickname) if tr else None,
"birth_year_hijri": tr.birth_year_hijri if tr else None,
"death_year_hijri": tr.death_year_hijri if tr else None,
"age_at_death": tr.age_at_death if tr else None,
"generation": tr.generation if tr else None,
"tadlis": tr.tadlis if tr else None,
"ikhtilat": tr.ikhtilat if tr else None,
"companion_type": tr.companion_type if tr else None,
"madhhab": tr.madhhab if tr else None,
"in_sahih_muslim": tr.in_sahih_muslim if tr else None,
"in_sahih_bukhari": tr.in_sahih_bukhari if tr else None,
"reliability": rel_data, "reliability": rel_data,
"thumbnail": self._get_thumbnail_url(tr.thumbnail, request),
"share_link": tr.share_link,
"is_uncertain": is_node_uncertain,
"uncertain_transmitters": uncertain_candidates,
"transmitter": {
"id": tr.id,
"name": self.get_localized(tr.full_name, lang) or tr.slug,
"full_name": self.get_localized(tr.full_name, lang),
"slug": tr.slug,
"reliability": rel_data,
} if tr else None,
"layer": layer_data,
"layer_number": layer_obj.number if layer_obj else None,
"layer_name": self.get_localized(layer_obj.name, lang) if layer_obj else None,
"thumbnail": self._get_thumbnail_url(tr.thumbnail, request) if tr else None,
"share_link": tr.share_link if tr else None,
} }
curr["children_map"][tr_key] = { curr["children_map"][tr_key] = {
"data": tr_node_data, "data": tr_node_data,
@ -705,18 +778,16 @@ class HadisCategoryXMindView(APIView):
} }
curr["children_map"][hadis_key]["leaf_hadiths"].add(hadis.id) curr["children_map"][hadis_key]["leaf_hadiths"].add(hadis.id)
# Convert internal trie to the standard XMind JSON tree structure
# Format prefix tree into recursive XMind JSON structure
def format_tree_node(node_dict, path_prefix=""): 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 = dict(node_dict["data"])
node_type = node_data.get("node_type", "unknown")
raw_id = node_data.get("id", "0")
unique_node_id = f"{path_prefix}_{node_type}_{raw_id}"
node_data["node_id"] = unique_node_id node_data["node_id"] = unique_node_id
attached = [] attached = []
for (child_type, child_id), child_subtree in children_map.items():
for (child_type, child_id), child_subtree in node_dict["children_map"].items():
formatted_child = format_tree_node(child_subtree, path_prefix=unique_node_id) formatted_child = format_tree_node(child_subtree, path_prefix=unique_node_id)
attached.append(formatted_child) attached.append(formatted_child)
@ -746,4 +817,8 @@ class HadisCategoryXMindView(APIView):
} }
} }
return Response(data)
response = Response(data)
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response['Pragma'] = 'no-cache'
response['Expires'] = '0'
return response

10
apps/hadis/views/hadis.py

@ -205,7 +205,7 @@ class HadisListView(ListAPIView):
category_obj = get_object_or_404(HadisCategory, slug=category_slug) category_obj = get_object_or_404(HadisCategory, slug=category_slug)
# 3. Serialize this single category for the Hero section # 3. Serialize this single category for the Hero section
category_data = SimpleCategory(category_obj).data
category_data = SimpleCategory(category_obj, context={'request': request}).data
# 4. Check if it's a Quran Category and fetch verse data # 4. Check if it's a Quran Category and fetch verse data
quran_data = None quran_data = None
@ -702,8 +702,12 @@ class CategoryHadisCorrectionsView(ListAPIView):
search_query = self.request.query_params.get('search', None) search_query = self.request.query_params.get('search', None)
if search_query: if search_query:
search_conditions = ( search_conditions = (
Q(title__icontains=search_query) |
Q(text__icontains=search_query) | Q(text__icontains=search_query) |
Q(narrator__icontains=search_query)
Q(narrator__icontains=search_query) |
Q(translation__icontains=search_query) |
Q(hadis__title__icontains=search_query) |
Q(hadis__title_narrator__icontains=search_query)
) )
queryset = queryset.filter(search_conditions) queryset = queryset.filter(search_conditions)
@ -731,7 +735,7 @@ class CategoryHadisCorrectionsView(ListAPIView):
response = super().list(request, *args, **kwargs) response = super().list(request, *args, **kwargs)
category_slug = self.kwargs.get('category_slug') category_slug = self.kwargs.get('category_slug')
category_obj = HadisCategory.objects.filter(slug=category_slug).first() category_obj = HadisCategory.objects.filter(slug=category_slug).first()
category_data = SimpleCategory(category_obj).data if category_obj else None
category_data = SimpleCategory(category_obj, context={'request': request}).data if category_obj else None
if isinstance(response.data, dict): if isinstance(response.data, dict):
ordered_data = { ordered_data = {

19
apps/hadis/views_admin.py

@ -968,6 +968,9 @@ class AdminContentReleaseViewSet(ModelViewSet):
is_active=True is_active=True
) )
from .signals import invalidate_hadis_cache
invalidate_hadis_cache()
return Response({ return Response({
"status": "success", "status": "success",
"message": f"Version incremented to {version_name}", "message": f"Version incremented to {version_name}",
@ -975,3 +978,19 @@ class AdminContentReleaseViewSet(ModelViewSet):
"next_suggested_version": compute_next_version(version_name) "next_suggested_version": compute_next_version(version_name)
}) })
def perform_create(self, serializer):
super().perform_create(serializer)
from .signals import invalidate_hadis_cache
invalidate_hadis_cache()
def perform_update(self, serializer):
super().perform_update(serializer)
from .signals import invalidate_hadis_cache
invalidate_hadis_cache()
def perform_destroy(self, instance):
super().perform_destroy(instance)
from .signals import invalidate_hadis_cache
invalidate_hadis_cache()
Loading…
Cancel
Save