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
from django.core.management.base import BaseCommand
from apps.hadis.models import HadisCorrection, HadisInterpretation
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):
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')
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 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):
if obj.book_reference:
@ -1633,15 +1642,23 @@ class HadisCorrectionSerializer(serializers.ModelSerializer):
address = serializers.SerializerMethodField()
images = serializers.SerializerMethodField()
links = serializers.SerializerMethodField()
references = serializers.SerializerMethodField()
hadis_info = serializers.SerializerMethodField()
class Meta:
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):
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):
if obj.hadis:
request = self.context.get('request')
@ -1705,6 +1722,7 @@ class HadisBasicSerializer(serializers.ModelSerializer):
category = serializers.SerializerMethodField()
categories = serializers.SerializerMethodField()
bookmark = serializers.SerializerMethodField()
links = serializers.SerializerMethodField()
title = LocalizedField()
title_narrator = LocalizedField()
@ -1715,9 +1733,12 @@ class HadisBasicSerializer(serializers.ModelSerializer):
model = Hadis
fields = [
'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):
cat = obj.category
if not cat and hasattr(obj, 'categories'):
@ -1782,6 +1803,7 @@ class HadisDetailSerializer(serializers.ModelSerializer):
)
reference_images = SerializerMethodField()
address = LocalizedField()
links = serializers.SerializerMethodField()
category_slug = serializers.CharField(source='category.slug', read_only=True)
category = serializers.SerializerMethodField()
categories = serializers.SerializerMethodField()
@ -1793,6 +1815,9 @@ class HadisDetailSerializer(serializers.ModelSerializer):
'tags', 'references', 'reference_images', 'address'
]
def get_links(self, obj):
return format_links_output(obj.links)
def get_category(self, obj):
cat = obj.category
if not cat and hasattr(obj, 'categories'):
@ -2109,6 +2134,15 @@ class DetailedCorrectionReferenceSerializer(BaseDetailedReferenceSerializer):
request = self.context.get('request')
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)
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
volume = serializers.SerializerMethodField()

110
apps/hadis/utils/links.py

@ -2,6 +2,45 @@ import re
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:
"""
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:
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:
parsed = urlparse(candidate)
@ -42,7 +79,8 @@ def extract_domain_from_url(url: str) -> str:
def normalize_links_for_storage(links_data):
"""
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.
"""
if not links_data:
@ -64,8 +102,11 @@ def normalize_links_for_storage(links_data):
normalized = {}
for title, url_or_obj in links_data.items():
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 ""
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"):
t = extract_domain_from_url(url) or "Link"
@ -78,10 +119,13 @@ def normalize_links_for_storage(links_data):
normalized[key] = url
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()
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"):
t = extract_domain_from_url(url) or "Link"
key = t
@ -97,14 +141,20 @@ def normalize_links_for_storage(links_data):
normalized = []
for item in links_data:
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"
normalized.append({"title": title, "url": url})
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()
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"):
title = extract_domain_from_url(url) or "Link"
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"}
]
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:
return []
@ -135,24 +186,31 @@ def format_links_output(links_data):
return format_links_output(parsed)
except (ValueError, TypeError, json.JSONDecodeError):
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 []
if isinstance(links_data, dict):
out = []
for title, url_or_obj in links_data.items():
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 ""
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"):
t = extract_domain_from_url(url) or "Link"
out.append({"title": t, "url": url})
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()
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"):
t = extract_domain_from_url(url) or "Link"
out.append({"title": t, "url": url})
@ -164,16 +222,22 @@ def format_links_output(links_data):
out = []
for item in links_data:
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({
"title": extract_domain_from_url(url) or "Link",
"url": url
})
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()
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"):
t = extract_domain_from_url(url) or "Link"
out.append({"title": t, "url": url})

54
apps/hadis/views/hadis.py

@ -825,7 +825,7 @@ class HadisCorrectionsView(ListAPIView):
"""
serializer_class = HadisCorrectionSerializer
authentication_classes = [TokenAuthentication]
permission_classes = [IsAuthenticated]
permission_classes = [AllowAny]
pagination_class = StandardResultsSetPagination
lookup_field = 'slug'
lookup_url_kwarg = 'hadis_slug'
@ -854,10 +854,19 @@ class HadisCorrectionsView(ListAPIView):
def get_queryset(self):
hadis_slug = self.kwargs.get('hadis_slug')
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
is_bookmark = self.request.query_params.get('is_bookmark', '').lower()
@ -956,6 +965,9 @@ class CategoryHadisCorrectionsView(ListAPIView):
hadis_id__in=hadis_ids
).select_related('hadis').prefetch_related(
'references',
'references__book_reference__author',
'references__edition',
'references__book_volume',
'references__images'
).order_by('priority', '-created_at')
@ -1341,6 +1353,21 @@ class HadisCorrectionDetailView(RetrieveAPIView):
'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):
"""API View to get full details of a specific HadisInterpretation by slug"""
serializer_class = HadisInterpretationDetailSerializer
@ -1403,12 +1430,23 @@ class HadisInterpretsListView(ListAPIView):
def get_queryset(self):
hadis_slug = self.kwargs.get('hadis_slug')
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.filter(
category_id=hadis.category_id
category_id__in=list(cat_ids)
).select_related(
'category'
).prefetch_related(

Loading…
Cancel
Save