Browse Source

fix(hadis): normalize external link URLs, include correction references, and remove dummy links

master
Mohsen Taba 3 days ago
parent
commit
c1cd52747b
  1. 30
      apps/hadis/management/commands/populate_narrator_links.py
  2. 40
      apps/hadis/serializers/hadis.py
  3. 110
      apps/hadis/utils/links.py
  4. 54
      apps/hadis/views/hadis.py

30
apps/hadis/management/commands/populate_narrator_links.py

@ -1,32 +1,14 @@
# backend/apps/hadis/management/commands/populate_narrator_links.py # backend/apps/hadis/management/commands/populate_narrator_links.py
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from apps.hadis.models import HadisCorrection, HadisInterpretation
class Command(BaseCommand): class Command(BaseCommand):
help = 'Populates the default narrator and links for all existing Corrections and Interpretations.'
help = 'Deprecated command (dummy narrator and links seeding disabled).'
def handle(self, *args, **options): def handle(self, *args, **options):
self.stdout.write(self.style.WARNING("Updating default values for narrator and links..."))
default_narrator = "زُرارَةُ بْنُ أَعْيَنَ الشَّيبانِيّ"
default_links = [
{"title": "Первая ссылка Первая ссылка ссылкассы...", "link": "google.com"},
{"title": "Первая ссылка.", "link": "google.com"},
{"title": "Первая ссылка.", "link": "google.com"}
]
# آپدیت تمام رکوردهای جدول تصحیحات
corr_count = HadisCorrection.objects.all().update(
narrator=default_narrator,
links=default_links
self.stdout.write(
self.style.WARNING(
"populate_narrator_links is disabled to prevent dummy links from being written."
)
) )
# آپدیت تمام رکوردهای جدول تفاسیر
interp_count = HadisInterpretation.objects.all().update(
narrator=default_narrator,
links=default_links
)
self.stdout.write(self.style.SUCCESS(f"✅ Successfully updated {corr_count} HadisCorrections!"))
self.stdout.write(self.style.SUCCESS(f"✅ Successfully updated {interp_count} HadisInterpretations!"))

40
apps/hadis/serializers/hadis.py

@ -1472,7 +1472,16 @@ class HadisReferenceSerializer(serializers.ModelSerializer):
request = self.context.get('request') request = self.context.get('request')
if obj.book_volume and obj.book_volume.file: if obj.book_volume and obj.book_volume.file:
return absolute_https_url(obj.book_volume.file.url, request) if request else absolute_https_url(obj.book_volume.file.url) return absolute_https_url(obj.book_volume.file.url, request) if request else absolute_https_url(obj.book_volume.file.url)
return obj.url or None
if getattr(obj, 'url', None):
return obj.url
edition = obj.edition
if not edition and getattr(obj, 'book_volume', None) and obj.book_volume.edition:
edition = obj.book_volume.edition
if not edition and getattr(obj, 'book_reference', None) and hasattr(obj.book_reference, 'editions'):
edition = obj.book_reference.editions.first()
if edition and getattr(edition, 'source_url', None):
return edition.source_url
return None
def get_book(self, obj): def get_book(self, obj):
if obj.book_reference: if obj.book_reference:
@ -1633,15 +1642,23 @@ class HadisCorrectionSerializer(serializers.ModelSerializer):
address = serializers.SerializerMethodField() address = serializers.SerializerMethodField()
images = serializers.SerializerMethodField() images = serializers.SerializerMethodField()
links = serializers.SerializerMethodField() links = serializers.SerializerMethodField()
references = serializers.SerializerMethodField()
hadis_info = serializers.SerializerMethodField() hadis_info = serializers.SerializerMethodField()
class Meta: class Meta:
model = HadisCorrection model = HadisCorrection
fields = ['id', 'title', 'slug', 'narrator', 'description', 'translation', 'share_link', 'bookmark', 'address', 'images', 'links', 'hadis_info']
fields = ['id', 'title', 'slug', 'narrator', 'description', 'translation', 'share_link', 'bookmark', 'address', 'images', 'links', 'references', 'hadis_info']
def get_links(self, obj): def get_links(self, obj):
return format_links_output(obj.links) return format_links_output(obj.links)
def get_references(self, obj):
return DetailedCorrectionReferenceSerializer(
obj.references.all(),
many=True,
context=self.context
).data
def get_hadis_info(self, obj): def get_hadis_info(self, obj):
if obj.hadis: if obj.hadis:
request = self.context.get('request') request = self.context.get('request')
@ -1705,6 +1722,7 @@ class HadisBasicSerializer(serializers.ModelSerializer):
category = serializers.SerializerMethodField() category = serializers.SerializerMethodField()
categories = serializers.SerializerMethodField() categories = serializers.SerializerMethodField()
bookmark = serializers.SerializerMethodField() bookmark = serializers.SerializerMethodField()
links = serializers.SerializerMethodField()
title = LocalizedField() title = LocalizedField()
title_narrator = LocalizedField() title_narrator = LocalizedField()
@ -1715,9 +1733,12 @@ class HadisBasicSerializer(serializers.ModelSerializer):
model = Hadis model = Hadis
fields = [ fields = [
'id', 'slug', 'title', 'bookmark', 'title_narrator', 'text', 'id', 'slug', 'title', 'bookmark', 'title_narrator', 'text',
'translation', 'share_link', 'explanation', 'category', 'categories'
'translation', 'share_link', 'explanation', 'category', 'categories', 'links'
] ]
def get_links(self, obj):
return format_links_output(obj.links)
def get_category(self, obj): def get_category(self, obj):
cat = obj.category cat = obj.category
if not cat and hasattr(obj, 'categories'): if not cat and hasattr(obj, 'categories'):
@ -1782,6 +1803,7 @@ class HadisDetailSerializer(serializers.ModelSerializer):
) )
reference_images = SerializerMethodField() reference_images = SerializerMethodField()
address = LocalizedField() address = LocalizedField()
links = serializers.SerializerMethodField()
category_slug = serializers.CharField(source='category.slug', read_only=True) category_slug = serializers.CharField(source='category.slug', read_only=True)
category = serializers.SerializerMethodField() category = serializers.SerializerMethodField()
categories = serializers.SerializerMethodField() categories = serializers.SerializerMethodField()
@ -1793,6 +1815,9 @@ class HadisDetailSerializer(serializers.ModelSerializer):
'tags', 'references', 'reference_images', 'address' 'tags', 'references', 'reference_images', 'address'
] ]
def get_links(self, obj):
return format_links_output(obj.links)
def get_category(self, obj): def get_category(self, obj):
cat = obj.category cat = obj.category
if not cat and hasattr(obj, 'categories'): if not cat and hasattr(obj, 'categories'):
@ -2109,6 +2134,15 @@ class DetailedCorrectionReferenceSerializer(BaseDetailedReferenceSerializer):
request = self.context.get('request') request = self.context.get('request')
if obj.book_volume and obj.book_volume.file: if obj.book_volume and obj.book_volume.file:
return absolute_https_url(obj.book_volume.file.url, request) if request else absolute_https_url(obj.book_volume.file.url) return absolute_https_url(obj.book_volume.file.url, request) if request else absolute_https_url(obj.book_volume.file.url)
if getattr(obj, 'url', None):
return obj.url
edition = obj.edition
if not edition and getattr(obj, 'book_volume', None) and obj.book_volume.edition:
edition = obj.book_volume.edition
if not edition and getattr(obj, 'book_reference', None) and hasattr(obj.book_reference, 'editions'):
edition = obj.book_reference.editions.first()
if edition and getattr(edition, 'source_url', None):
return edition.source_url
return None return None
volume = serializers.SerializerMethodField() volume = serializers.SerializerMethodField()

110
apps/hadis/utils/links.py

@ -2,6 +2,45 @@ import re
from urllib.parse import urlparse from urllib.parse import urlparse
def ensure_external_url(url: str) -> str:
"""
Ensures a URL string has an explicit http:// or https:// scheme so browsers
never treat domain-only links (e.g. 'mamad.com') as relative paths.
"""
if not url or not isinstance(url, str):
return ""
trimmed = url.strip()
if not trimmed:
return ""
if re.match(r"^[a-zA-Z][a-zA-Z0-9+\-.]*://", trimmed):
return trimmed
if trimmed.startswith("//"):
return "https:" + trimmed
if trimmed.startswith("/"):
return trimmed
return "https://" + trimmed.lstrip("/")
def is_dummy_placeholder_link(title: str, url: str) -> bool:
"""
Detects legacy seeded dummy links ('Первая ссылка...' -> 'google.com')
so they are never stored or returned.
"""
t = (title or "").strip()
u = (url or "").strip().lower()
if "Первая ссылка" in t:
return True
if u in (
"google.com",
"https://google.com",
"http://google.com",
"https://www.google.com",
"http://www.google.com",
) and ("Первая" in t or t in ("google.com", "Link", "")):
return True
return False
def extract_domain_from_url(url: str) -> str: def extract_domain_from_url(url: str) -> str:
""" """
Extracts the root domain / hostname without 'www.' from a URL. Extracts the root domain / hostname without 'www.' from a URL.
@ -17,9 +56,7 @@ def extract_domain_from_url(url: str) -> str:
if not trimmed: if not trimmed:
return "" return ""
candidate = trimmed
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+\-.]*://", candidate):
candidate = "https://" + candidate
candidate = ensure_external_url(trimmed)
try: try:
parsed = urlparse(candidate) parsed = urlparse(candidate)
@ -42,7 +79,8 @@ def extract_domain_from_url(url: str) -> str:
def normalize_links_for_storage(links_data): def normalize_links_for_storage(links_data):
""" """
Ensures any link without a title (or with an empty / generic placeholder title) Ensures any link without a title (or with an empty / generic placeholder title)
has its title automatically set to the domain of the URL.
has its title automatically set to the domain of the URL, and ensures the URL
starts with http:// or https://.
Returns the normalized structure ready to be stored in JSONField. Returns the normalized structure ready to be stored in JSONField.
""" """
if not links_data: if not links_data:
@ -64,8 +102,11 @@ def normalize_links_for_storage(links_data):
normalized = {} normalized = {}
for title, url_or_obj in links_data.items(): for title, url_or_obj in links_data.items():
if isinstance(url_or_obj, str) and url_or_obj.strip(): if isinstance(url_or_obj, str) and url_or_obj.strip():
url = url_or_obj.strip()
raw_url = url_or_obj.strip()
t = str(title).strip() if title else "" t = str(title).strip() if title else ""
if is_dummy_placeholder_link(t, raw_url):
continue
url = ensure_external_url(raw_url)
if not t or t.lower() in ("link", "untitled", "url", "default"): if not t or t.lower() in ("link", "untitled", "url", "default"):
t = extract_domain_from_url(url) or "Link" t = extract_domain_from_url(url) or "Link"
@ -78,10 +119,13 @@ def normalize_links_for_storage(links_data):
normalized[key] = url normalized[key] = url
elif isinstance(url_or_obj, dict): elif isinstance(url_or_obj, dict):
url = url_or_obj.get("url") or url_or_obj.get("link") or url_or_obj.get("href")
if url and isinstance(url, str) and url.strip():
url = url.strip()
raw_url = url_or_obj.get("url") or url_or_obj.get("link") or url_or_obj.get("href")
if raw_url and isinstance(raw_url, str) and raw_url.strip():
raw_url = raw_url.strip()
t = str(url_or_obj.get("title") or title or "").strip() t = str(url_or_obj.get("title") or title or "").strip()
if is_dummy_placeholder_link(t, raw_url):
continue
url = ensure_external_url(raw_url)
if not t or t.lower() in ("link", "untitled", "url", "default"): if not t or t.lower() in ("link", "untitled", "url", "default"):
t = extract_domain_from_url(url) or "Link" t = extract_domain_from_url(url) or "Link"
key = t key = t
@ -97,14 +141,20 @@ def normalize_links_for_storage(links_data):
normalized = [] normalized = []
for item in links_data: for item in links_data:
if isinstance(item, str) and item.strip(): if isinstance(item, str) and item.strip():
url = item.strip()
raw_url = item.strip()
if is_dummy_placeholder_link("", raw_url):
continue
url = ensure_external_url(raw_url)
title = extract_domain_from_url(url) or "Link" title = extract_domain_from_url(url) or "Link"
normalized.append({"title": title, "url": url}) normalized.append({"title": title, "url": url})
elif isinstance(item, dict): elif isinstance(item, dict):
url = item.get("url") or item.get("link") or item.get("href") or item.get("value")
if url and isinstance(url, str) and url.strip():
url = url.strip()
raw_url = item.get("url") or item.get("link") or item.get("href") or item.get("value")
if raw_url and isinstance(raw_url, str) and raw_url.strip():
raw_url = raw_url.strip()
title = str(item.get("title") or item.get("name") or "").strip() title = str(item.get("title") or item.get("name") or "").strip()
if is_dummy_placeholder_link(title, raw_url):
continue
url = ensure_external_url(raw_url)
if not title or title.lower() in ("link", "untitled", "url", "default"): if not title or title.lower() in ("link", "untitled", "url", "default"):
title = extract_domain_from_url(url) or "Link" title = extract_domain_from_url(url) or "Link"
normalized.append({"title": title, "url": url}) normalized.append({"title": title, "url": url})
@ -121,7 +171,8 @@ def format_links_output(links_data):
{"title": "islamweb.net", "url": "https://www.islamweb.net/ar/fatwa/1909"} {"title": "islamweb.net", "url": "https://www.islamweb.net/ar/fatwa/1909"}
] ]
Handles dict, list of dicts, list of strings, and raw JSON strings cleanly. Handles dict, list of dicts, list of strings, and raw JSON strings cleanly.
Always ensures title defaults to domain if title is empty or generic 'Link'.
Always ensures title defaults to domain if title is empty or generic 'Link',
and ensures url has an explicit http(s):// scheme.
""" """
if not links_data: if not links_data:
return [] return []
@ -135,24 +186,31 @@ def format_links_output(links_data):
return format_links_output(parsed) return format_links_output(parsed)
except (ValueError, TypeError, json.JSONDecodeError): except (ValueError, TypeError, json.JSONDecodeError):
pass pass
if trimmed:
return [{"title": extract_domain_from_url(trimmed) or "Link", "url": trimmed}]
if trimmed and not is_dummy_placeholder_link("", trimmed):
url = ensure_external_url(trimmed)
return [{"title": extract_domain_from_url(url) or "Link", "url": url}]
return [] return []
if isinstance(links_data, dict): if isinstance(links_data, dict):
out = [] out = []
for title, url_or_obj in links_data.items(): for title, url_or_obj in links_data.items():
if isinstance(url_or_obj, str) and url_or_obj.strip(): if isinstance(url_or_obj, str) and url_or_obj.strip():
url = url_or_obj.strip()
raw_url = url_or_obj.strip()
t = str(title).strip() if title else "" t = str(title).strip() if title else ""
if is_dummy_placeholder_link(t, raw_url):
continue
url = ensure_external_url(raw_url)
if not t or t.lower() in ("link", "untitled", "url", "default"): if not t or t.lower() in ("link", "untitled", "url", "default"):
t = extract_domain_from_url(url) or "Link" t = extract_domain_from_url(url) or "Link"
out.append({"title": t, "url": url}) out.append({"title": t, "url": url})
elif isinstance(url_or_obj, dict): elif isinstance(url_or_obj, dict):
url = url_or_obj.get("url") or url_or_obj.get("link") or url_or_obj.get("href")
if url and isinstance(url, str) and url.strip():
url = url.strip()
raw_url = url_or_obj.get("url") or url_or_obj.get("link") or url_or_obj.get("href")
if raw_url and isinstance(raw_url, str) and raw_url.strip():
raw_url = raw_url.strip()
t = str(url_or_obj.get("title") or title or "").strip() t = str(url_or_obj.get("title") or title or "").strip()
if is_dummy_placeholder_link(t, raw_url):
continue
url = ensure_external_url(raw_url)
if not t or t.lower() in ("link", "untitled", "url", "default"): if not t or t.lower() in ("link", "untitled", "url", "default"):
t = extract_domain_from_url(url) or "Link" t = extract_domain_from_url(url) or "Link"
out.append({"title": t, "url": url}) out.append({"title": t, "url": url})
@ -164,16 +222,22 @@ def format_links_output(links_data):
out = [] out = []
for item in links_data: for item in links_data:
if isinstance(item, str) and item.strip(): if isinstance(item, str) and item.strip():
url = item.strip()
raw_url = item.strip()
if is_dummy_placeholder_link("", raw_url):
continue
url = ensure_external_url(raw_url)
out.append({ out.append({
"title": extract_domain_from_url(url) or "Link", "title": extract_domain_from_url(url) or "Link",
"url": url "url": url
}) })
elif isinstance(item, dict): elif isinstance(item, dict):
url = item.get("url") or item.get("link") or item.get("href") or item.get("value")
if url and isinstance(url, str) and url.strip():
url = url.strip()
raw_url = item.get("url") or item.get("link") or item.get("href") or item.get("value")
if raw_url and isinstance(raw_url, str) and raw_url.strip():
raw_url = raw_url.strip()
t = str(item.get("title") or item.get("name") or "").strip() t = str(item.get("title") or item.get("name") or "").strip()
if is_dummy_placeholder_link(t, raw_url):
continue
url = ensure_external_url(raw_url)
if not t or t.lower() in ("link", "untitled", "url", "default"): if not t or t.lower() in ("link", "untitled", "url", "default"):
t = extract_domain_from_url(url) or "Link" t = extract_domain_from_url(url) or "Link"
out.append({"title": t, "url": url}) out.append({"title": t, "url": url})

54
apps/hadis/views/hadis.py

@ -825,7 +825,7 @@ class HadisCorrectionsView(ListAPIView):
""" """
serializer_class = HadisCorrectionSerializer serializer_class = HadisCorrectionSerializer
authentication_classes = [TokenAuthentication] authentication_classes = [TokenAuthentication]
permission_classes = [IsAuthenticated]
permission_classes = [AllowAny]
pagination_class = StandardResultsSetPagination pagination_class = StandardResultsSetPagination
lookup_field = 'slug' lookup_field = 'slug'
lookup_url_kwarg = 'hadis_slug' lookup_url_kwarg = 'hadis_slug'
@ -854,10 +854,19 @@ class HadisCorrectionsView(ListAPIView):
def get_queryset(self): def get_queryset(self):
hadis_slug = self.kwargs.get('hadis_slug') hadis_slug = self.kwargs.get('hadis_slug')
try: try:
hadis = Hadis.objects.get(slug=hadis_slug, status=True)
if not HadisCorrection.objects.filter(hadis=hadis).exists():
return Hadis.objects.none()
queryset = HadisCorrection.objects.filter(hadis=hadis)
hadis = Hadis.objects.filter(slug=hadis_slug, status=True).first()
if not hadis and str(hadis_slug).isdigit():
hadis = Hadis.objects.filter(id=int(hadis_slug), status=True).first()
if not hadis:
return HadisCorrection.objects.none()
queryset = HadisCorrection.objects.filter(hadis=hadis).select_related(
'hadis'
).prefetch_related(
'references__book_reference__author',
'references__edition',
'references__book_volume',
'references__images'
).order_by('priority', '-created_at')
# Filter by bookmarks if provided # Filter by bookmarks if provided
is_bookmark = self.request.query_params.get('is_bookmark', '').lower() is_bookmark = self.request.query_params.get('is_bookmark', '').lower()
@ -956,6 +965,9 @@ class CategoryHadisCorrectionsView(ListAPIView):
hadis_id__in=hadis_ids hadis_id__in=hadis_ids
).select_related('hadis').prefetch_related( ).select_related('hadis').prefetch_related(
'references', 'references',
'references__book_reference__author',
'references__edition',
'references__book_volume',
'references__images' 'references__images'
).order_by('priority', '-created_at') ).order_by('priority', '-created_at')
@ -1341,6 +1353,21 @@ class HadisCorrectionDetailView(RetrieveAPIView):
'references__images' 'references__images'
) )
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
obj = queryset.filter(**filter_kwargs).first()
if not obj and str(self.kwargs[lookup_url_kwarg]).isdigit():
obj = queryset.filter(id=int(self.kwargs[lookup_url_kwarg])).first()
if not obj:
raise Http404("No HadisCorrection matches the given query.")
self.check_object_permissions(self.request, obj)
return obj
class HadisInterpretationDetailView(RetrieveAPIView): class HadisInterpretationDetailView(RetrieveAPIView):
"""API View to get full details of a specific HadisInterpretation by slug""" """API View to get full details of a specific HadisInterpretation by slug"""
serializer_class = HadisInterpretationDetailSerializer serializer_class = HadisInterpretationDetailSerializer
@ -1403,12 +1430,23 @@ class HadisInterpretsListView(ListAPIView):
def get_queryset(self): def get_queryset(self):
hadis_slug = self.kwargs.get('hadis_slug') hadis_slug = self.kwargs.get('hadis_slug')
try: try:
hadis = Hadis.objects.select_related('category').get(slug=hadis_slug, status=True)
if not hadis.category_id:
hadis = Hadis.objects.select_related('category').prefetch_related('categories').filter(slug=hadis_slug, status=True).first()
if not hadis and str(hadis_slug).isdigit():
hadis = Hadis.objects.select_related('category').prefetch_related('categories').filter(id=int(hadis_slug), status=True).first()
if not hadis:
return HadisInterpretation.objects.none()
cat_ids = set()
if hadis.category_id:
cat_ids.add(hadis.category_id)
if hasattr(hadis, 'categories'):
cat_ids.update(hadis.categories.values_list('id', flat=True))
if not cat_ids:
return HadisInterpretation.objects.none() return HadisInterpretation.objects.none()
return HadisInterpretation.objects.filter( return HadisInterpretation.objects.filter(
category_id=hadis.category_id
category_id__in=list(cat_ids)
).select_related( ).select_related(
'category' 'category'
).prefetch_related( ).prefetch_related(

Loading…
Cancel
Save