Browse Source

SEO : seo tools and normalizations added for code base and Article, Hadis (optimized to prefetch transmitters, references, and book authors), Library (Books), Podcast, and Video apps.

master
Mohsen Taba 1 week ago
parent
commit
b1108ca0b4
  1. 8
      apps/article/models.py
  2. 100
      apps/article/sitemaps.py
  3. 8
      apps/article/views.py
  4. 8
      apps/hadis/models/hadis.py
  5. 10
      apps/hadis/models/reference.py
  6. 69
      apps/hadis/sitemaps.py
  7. 8
      apps/hadis/views/hadis.py
  8. 6
      apps/library/models.py
  9. 62
      apps/library/sitemaps.py
  10. 10
      apps/library/views.py
  11. 11
      apps/podcast/models.py
  12. 62
      apps/podcast/sitemaps.py
  13. 8
      apps/podcast/views.py
  14. 6
      apps/video/models.py
  15. 62
      apps/video/sitemaps.py
  16. 8
      apps/video/views.py
  17. 32
      config/middleware/site_middleware.py
  18. 4
      config/settings/base.py
  19. 17
      config/urls.py
  20. 16
      templates/sitemap.xml
  21. 6
      utils/exceptions.py
  22. 96
      utils/mixins.py

8
apps/article/models.py

@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from django.conf import settings
from utils import generate_slug_for_model
from utils.mixins import LowercaseSlugMixin
class ArticleCategory(models.Model):
@ -83,7 +84,8 @@ class MiddleArticleCollection(ArticleCollection):
verbose_name_plural = _('Regular Collections (Middle Section)')
class Article(models.Model):
class Article(LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
title = models.CharField(max_length=255, null=True)
slug = models.SlugField(allow_unicode=True, unique=True)
@ -125,16 +127,12 @@ class Article(models.Model):
return self.view_count
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(Article, self.title)
# Reset embedded_in if text or translation changes
if self.pk:
old_instance = Article.objects.get(pk=self.pk)
if (old_instance.description != self.description or
old_instance.content != self.content):
self.embedded_in = [] # Reset!
super().save(*args, **kwargs)
class Meta:

100
apps/article/sitemaps.py

@ -0,0 +1,100 @@
import datetime
from django.contrib.sitemaps import Sitemap
from django.conf import settings
from django.utils import timezone
from apps.article.models import Article
class BaseCanonicalSitemap(Sitemap):
"""
Base Sitemap class that supports absolute frontend URLs.
Strips out the django-prepended backend scheme and domain from the final URLs
if the location returned is already an absolute URL.
Also ensures all `lastmod` datetimes are rendered in strict W3C / ISO 8601 format with timezone offsets.
"""
limit = 10000
def _urls(self, page, protocol, domain):
urls = super()._urls(page, protocol, domain)
for url_info in urls:
# Fix absolute locations
url_info["location"] = self._fix_absolute_url(url_info["location"])
if "alternates" in url_info:
for alt in url_info["alternates"]:
alt["location"] = self._fix_absolute_url(alt["location"])
# Format naive/aware lastmod datetime objects to strict ISO 8601 / W3C format
lastmod_dt = url_info.get("lastmod")
if isinstance(lastmod_dt, (datetime.datetime, datetime.date)):
if isinstance(lastmod_dt, datetime.datetime):
if timezone.is_naive(lastmod_dt):
lastmod_dt = timezone.make_aware(lastmod_dt)
url_info["lastmod"] = lastmod_dt.isoformat()
return urls
def _fix_absolute_url(self, url):
# Look for a secondary protocol pattern (e.g., http://testserverhttps://domain.com)
for scheme in ("https://", "http://"):
idx = url.find(scheme, 7) # search after the first scheme prefix
if idx != -1:
return url[idx:]
return url
class ArticleSitemap(BaseCanonicalSitemap):
"""
Sitemap class for Article model.
Overridden to dynamically build alternate (hreflang) URLs for all supported languages,
format lastmod to strict W3C / ISO 8601 with timezone offsets, and output directly to XML.
"""
changefreq = "weekly"
priority = 0.6
def items(self):
# Only include active/published articles, sorted by updated_at
return Article.objects.filter(status=True).order_by("-updated_at")
def lastmod(self, obj):
# Use the article's update timestamp for last modification info
return obj.updated_at
def location(self, obj):
# Use the frontend URL via the share_link property
return obj.share_link
def get_urls(self, page=1, site=None, protocol=None):
urls = []
paginator_page = self.paginator.page(page)
for item in paginator_page.object_list:
alternates = []
# Dynamically build alternate URLs for all supported languages
for lang_code, lang_name in settings.LANGUAGES:
localized_url = f"{settings.DOVODI_DOMAIN}/{lang_code}/articles/{item.slug}"
alternates.append({
"location": localized_url,
"lang_code": lang_code,
})
# Safely format naive/aware lastmod datetime to strict ISO 8601 / W3C format
lastmod_dt = item.updated_at
if lastmod_dt:
if timezone.is_naive(lastmod_dt):
lastmod_dt = timezone.make_aware(lastmod_dt)
lastmod_str = lastmod_dt.isoformat()
else:
lastmod_str = None
# Construct URL metadata dictionary
url_info = {
"item": item,
"location": item.share_link,
"lastmod": lastmod_str,
"changefreq": self.changefreq,
"priority": str(self.priority),
"alternates": alternates,
}
urls.append(url_info)
return urls

8
apps/article/views.py

@ -11,6 +11,7 @@ from rest_framework.permissions import IsAuthenticated
from utils.pagination import StandardResultsSetPagination
from utils.mixins import CanonicalSlugViewSetMixin, Gone410ViewMixin
from apps.article.models import *
from apps.article.serializers import *
from apps.article.serializers_dovodi import (
@ -223,12 +224,15 @@ class ArticleListAPIView(generics.ListAPIView):
return queryset
class ArticleDetailAPIView(generics.RetrieveAPIView):
class ArticleDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics.RetrieveAPIView):
serializer_class = ArticleDetailSerializer
permission_classes = (IsAuthenticated,)
authentication_classes = [TokenAuthentication]
lookup_field = 'slug'
active_field_name = 'status'
active_expected_value = True
@swagger_auto_schema(
operation_description="Get article details by slug",
tags=["Dobodbi - Article"],
@ -243,7 +247,7 @@ class ArticleDetailAPIView(generics.RetrieveAPIView):
return super().get(request, *args, **kwargs)
def get_queryset(self):
return Article.objects.filter(status=True)
return Article.objects.all()
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()

8
apps/hadis/models/hadis.py

@ -7,7 +7,7 @@ from django.conf import settings
from django.utils.text import slugify
from .reference import BookReference
from utils.slug import generate_smart_slug
from utils.mixins import ColorPaletteMixin
from utils.mixins import ColorPaletteMixin, LowercaseSlugMixin
class HadisCollection(models.Model):
title = models.JSONField(default = list , verbose_name=_('Title'))
@ -259,7 +259,8 @@ class HadisStatus(ColorPaletteMixin,models.Model):
class Hadis(models.Model):
class Hadis(LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
category = models.ForeignKey("hadis.HadisCategory", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('category'))
number = models.PositiveIntegerField(verbose_name=_('number'), default=1)
legacy_id = models.CharField(max_length=255, unique=True, null=True, blank=True, db_index=True)
@ -327,8 +328,9 @@ class Hadis(models.Model):
counter += 1
self.slug = base_slug
# Generate/update share_link before saving
# Lowercase the slug and generate/update share_link before saving
if self.slug:
self.slug = self.slug.lower()
self.share_link = f"{settings.DOVODI_DOMAIN}/arguments/hadith/{self.slug}"
# Reset embedded_in if text or translation changes

10
apps/hadis/models/reference.py

@ -172,6 +172,16 @@ class BookReference(models.Model):
return f"{settings.DOVODI_DOMAIN}/arguments/sources/{self.slug}"
return None
@property
def authors(self):
"""
Backward compatibility helper returning a queryset of authors.
Allows serializers calling obj.authors.all() to function correctly.
"""
if self.author_id:
return BookAuthor.objects.filter(id=self.author_id)
return BookAuthor.objects.none()
def get_title(self,lang):
return self._get_json_field("title" , lang)

69
apps/hadis/sitemaps.py

@ -0,0 +1,69 @@
import datetime
from django.conf import settings
from django.utils import timezone
from apps.article.sitemaps import BaseCanonicalSitemap
from apps.hadis.models import Hadis
class HadisSitemap(BaseCanonicalSitemap):
"""
Sitemap class for Hadis model.
Dynamically builds alternate (hreflang) URLs for all supported languages,
formats lastmod to strict W3C / ISO 8601 with timezone offsets, and outputs directly to XML.
"""
changefreq = "weekly"
priority = 0.6
def items(self):
# Only include active/published hadiths
return Hadis.objects.filter(status=True).select_related(
'category',
'hadis_status'
).prefetch_related(
'transmitters__transmitter',
'transmitters__narrator_layer',
'references__book_reference__author'
).order_by("-updated_at")
def lastmod(self, obj):
return obj.updated_at
def location(self, obj):
return obj.share_link
def get_urls(self, page=1, site=None, protocol=None):
urls = []
paginator_page = self.paginator.page(page)
for item in paginator_page.object_list:
alternates = []
# Dynamically build alternate URLs for all supported languages
for lang_code, lang_name in settings.LANGUAGES:
localized_url = f"{settings.DOVODI_DOMAIN}/{lang_code}/arguments/hadith/{item.slug}"
alternates.append({
"location": localized_url,
"lang_code": lang_code,
})
# Safely format naive/aware lastmod datetime to strict ISO 8601 / W3C format
lastmod_dt = item.updated_at
if lastmod_dt:
if timezone.is_naive(lastmod_dt):
lastmod_dt = timezone.make_aware(lastmod_dt)
lastmod_str = lastmod_dt.isoformat()
else:
lastmod_str = None
# Construct URL metadata dictionary
url_info = {
"item": item,
"location": item.share_link,
"lastmod": lastmod_str,
"changefreq": self.changefreq,
"priority": str(self.priority),
"alternates": alternates,
}
urls.append(url_info)
return urls

8
apps/hadis/views/hadis.py

@ -15,6 +15,7 @@ from django.db.models import Q
from ..serializers.category import get_localized_text
from rest_framework import status
from utils.mixins import CanonicalSlugViewSetMixin, Gone410ViewMixin
class PinnedHadisCollectionListView(ListAPIView):
"""
@ -512,7 +513,7 @@ class HadisBasicView(RetrieveAPIView):
return context
class HadisDetailView(RetrieveAPIView):
class HadisDetailView(Gone410ViewMixin, CanonicalSlugViewSetMixin, RetrieveAPIView):
"""
API view to retrieve detailed Hadis information by hadis_slug (excluding transmitters and corrections)
"""
@ -520,12 +521,15 @@ class HadisDetailView(RetrieveAPIView):
lookup_field = 'slug'
lookup_url_kwarg = 'hadis_slug'
active_field_name = 'status'
active_expected_value = True
@hadis_detail_swagger
def get(self, request, *args, **kwargs):
return self.retrieve(request, *args, **kwargs)
def get_queryset(self):
return Hadis.objects.filter(status=True).select_related(
return Hadis.objects.all().select_related(
'category', 'hadis_status'
).prefetch_related(
'tags',

6
apps/library/models.py

@ -8,6 +8,7 @@ from dj_language.field import LanguageField
from utils import generate_slug_for_model
from utils.slug import generate_smart_slug
from apps.account.models import User
from utils.mixins import LowercaseSlugMixin
class BookCollection(models.Model):
@ -126,7 +127,8 @@ class Author(models.Model):
return self.books.filter(status=True).count()
class Book(models.Model):
class Book(LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
class FileType(models.TextChoices):
pdf = 'pdf', 'Pdf'
epub = 'epub', 'Epub'
@ -189,8 +191,6 @@ class Book(models.Model):
return None
def save(self, *args, **kwargs):
if not self.slug or not self.slug.strip():
self.slug = generate_smart_slug(self.title, Book, instance=self)
super().save(*args, **kwargs)

62
apps/library/sitemaps.py

@ -0,0 +1,62 @@
import datetime
from django.conf import settings
from django.utils import timezone
from apps.article.sitemaps import BaseCanonicalSitemap
from apps.library.models import Book
class BookSitemap(BaseCanonicalSitemap):
"""
Sitemap class for Book model.
Dynamically builds alternate (hreflang) URLs for all supported languages,
formats lastmod to strict W3C / ISO 8601 with timezone offsets, and outputs directly to XML.
"""
changefreq = "weekly"
priority = 0.6
def items(self):
# Only include active/published books
return Book.objects.filter(status=True).order_by("-updated_at")
def lastmod(self, obj):
return obj.updated_at
def location(self, obj):
return obj.share_link
def get_urls(self, page=1, site=None, protocol=None):
urls = []
paginator_page = self.paginator.page(page)
for item in paginator_page.object_list:
alternates = []
# Dynamically build alternate URLs for all supported languages
for lang_code, lang_name in settings.LANGUAGES:
localized_url = f"{settings.DOVODI_DOMAIN}/{lang_code}/library/{item.slug}"
alternates.append({
"location": localized_url,
"lang_code": lang_code,
})
# Safely format naive/aware lastmod datetime to strict ISO 8601 / W3C format
lastmod_dt = item.updated_at
if lastmod_dt:
if timezone.is_naive(lastmod_dt):
lastmod_dt = timezone.make_aware(lastmod_dt)
lastmod_str = lastmod_dt.isoformat()
else:
lastmod_str = None
# Construct URL metadata dictionary
url_info = {
"item": item,
"location": item.share_link,
"lastmod": lastmod_str,
"changefreq": self.changefreq,
"priority": str(self.priority),
"alternates": alternates,
}
urls.append(url_info)
return urls

10
apps/library/views.py

@ -9,7 +9,8 @@ from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from apps.library.pagination import NoPagination
from utils.pagination import StandardResultsSetPagination
from utils.pagination import StandardResultsSetPagination, NoPagination
from utils.mixins import CanonicalSlugViewSetMixin, Gone410ViewMixin
from apps.library.models import *
from apps.library.serializers import *
from apps.account.models import User
@ -206,16 +207,19 @@ class BookListView(ListAPIView):
return queryset
class BookDetailView(RetrieveAPIView):
class BookDetailView(Gone410ViewMixin, CanonicalSlugViewSetMixin, RetrieveAPIView):
"""
API view to retrieve detailed information about a specific book
"""
serializer_class = BookSerializer
permission_classes = (IsAuthenticated,)
authentication_classes = [TokenAuthentication]
queryset = Book.objects.filter(status=True)
queryset = Book.objects.all()
lookup_field = 'slug'
active_field_name = 'status'
active_expected_value = True
@book_detail_swagger
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)

11
apps/podcast/models.py

@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from django.conf import settings
from utils import generate_slug_for_model
from utils.mixins import LowercaseSlugMixin
class PodcastCategory(models.Model):
@ -78,7 +79,8 @@ class MiddlePodcastCollection(PodcastCollection):
class Podcast(models.Model):
class Podcast(LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
title = models.CharField(max_length=255, null=True)
slug = models.SlugField(allow_unicode=True, unique=True)
@ -113,8 +115,6 @@ class Podcast(models.Model):
return self.view_count
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(Podcast, self.title)
super().save(*args, **kwargs)
@ -124,7 +124,8 @@ class Podcast(models.Model):
verbose_name_plural = _('Podcasts')
class PodcastPlaylist(models.Model):
class PodcastPlaylist(LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
title = models.CharField(max_length=255, verbose_name=_('title'))
slug = models.SlugField(allow_unicode=True, unique=True, null=True, blank=True, verbose_name=_('slug'))
slogan = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('slogan'))
@ -174,8 +175,6 @@ class PodcastPlaylist(models.Model):
return timedelta(seconds=total_seconds)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(PodcastPlaylist, self.title)
super().save(*args, **kwargs)
class Meta:

62
apps/podcast/sitemaps.py

@ -0,0 +1,62 @@
import datetime
from django.conf import settings
from django.utils import timezone
from apps.article.sitemaps import BaseCanonicalSitemap
from apps.podcast.models import Podcast
class PodcastSitemap(BaseCanonicalSitemap):
"""
Sitemap class for Podcast model.
Dynamically builds alternate (hreflang) URLs for all supported languages,
formats lastmod to strict W3C / ISO 8601 with timezone offsets, and outputs directly to XML.
"""
changefreq = "weekly"
priority = 0.6
def items(self):
# Only include active/published podcasts
return Podcast.objects.filter(status=True).order_by("-updated_at")
def lastmod(self, obj):
return obj.updated_at
def location(self, obj):
return obj.share_link
def get_urls(self, page=1, site=None, protocol=None):
urls = []
paginator_page = self.paginator.page(page)
for item in paginator_page.object_list:
alternates = []
# Dynamically build alternate URLs for all supported languages
for lang_code, lang_name in settings.LANGUAGES:
localized_url = f"{settings.DOVODI_DOMAIN}/{lang_code}/podcast/{item.slug}"
alternates.append({
"location": localized_url,
"lang_code": lang_code,
})
# Safely format naive/aware lastmod datetime to strict ISO 8601 / W3C format
lastmod_dt = item.updated_at
if lastmod_dt:
if timezone.is_naive(lastmod_dt):
lastmod_dt = timezone.make_aware(lastmod_dt)
lastmod_str = lastmod_dt.isoformat()
else:
lastmod_str = None
# Construct URL metadata dictionary
url_info = {
"item": item,
"location": item.share_link,
"lastmod": lastmod_str,
"changefreq": self.changefreq,
"priority": str(self.priority),
"alternates": alternates,
}
urls.append(url_info)
return urls

8
apps/podcast/views.py

@ -8,6 +8,7 @@ from rest_framework.viewsets import ModelViewSet
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from apps.library.pagination import NoPagination
from utils.mixins import CanonicalSlugViewSetMixin, Gone410ViewMixin
from apps.podcast.models import *
@ -232,7 +233,7 @@ class PodcastListAPIView(generics.ListAPIView):
return queryset
class PodcastDetailAPIView(generics.RetrieveAPIView):
class PodcastDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics.RetrieveAPIView):
"""
API view to retrieve details of a specific podcast playlist
"""
@ -241,6 +242,9 @@ class PodcastDetailAPIView(generics.RetrieveAPIView):
permission_classes = (IsAuthenticated,)
authentication_classes = [TokenAuthentication]
active_field_name = 'status'
active_expected_value = True
@swagger_auto_schema(
operation_description="Get podcast playlist details by slug",
tags=["Dobodbi - Podcast"],
@ -255,7 +259,7 @@ class PodcastDetailAPIView(generics.RetrieveAPIView):
return super().get(request, *args, **kwargs)
def get_queryset(self):
return PodcastPlaylist.objects.filter(status=True)
return PodcastPlaylist.objects.all()
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()

6
apps/video/models.py

@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from django.conf import settings
from filer.fields.image import FilerImageField
from utils import generate_slug_for_model
from utils.mixins import LowercaseSlugMixin
from django.core.validators import FileExtensionValidator
from django.core.exceptions import ValidationError
@ -81,7 +82,8 @@ class MiddleVideoCollection(VideoCollection):
class Video(models.Model):
class Video(LowercaseSlugMixin, models.Model):
slug_source_field = 'title'
class VedioTypeChoices(models.TextChoices):
YOUTUBE_LINK = 'youtube_link', 'Youtube Link'
VIDEO_FILE = 'video_file', 'Video File'
@ -134,8 +136,6 @@ class Video(models.Model):
})
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(Video, self.title)
super().save(*args, **kwargs)

62
apps/video/sitemaps.py

@ -0,0 +1,62 @@
import datetime
from django.conf import settings
from django.utils import timezone
from apps.article.sitemaps import BaseCanonicalSitemap
from apps.video.models import Video
class VideoSitemap(BaseCanonicalSitemap):
"""
Sitemap class for Video model.
Dynamically builds alternate (hreflang) URLs for all supported languages,
formats lastmod to strict W3C / ISO 8601 with timezone offsets, and outputs directly to XML.
"""
changefreq = "weekly"
priority = 0.6
def items(self):
# Only include active/published videos
return Video.objects.filter(status=True).order_by("-updated_at")
def lastmod(self, obj):
return obj.updated_at
def location(self, obj):
return obj.share_link
def get_urls(self, page=1, site=None, protocol=None):
urls = []
paginator_page = self.paginator.page(page)
for item in paginator_page.object_list:
alternates = []
# Dynamically build alternate URLs for all supported languages
for lang_code, lang_name in settings.LANGUAGES:
localized_url = f"{settings.DOVODI_DOMAIN}/{lang_code}/videos/{item.slug}"
alternates.append({
"location": localized_url,
"lang_code": lang_code,
})
# Safely format naive/aware lastmod datetime to strict ISO 8601 / W3C format
lastmod_dt = item.updated_at
if lastmod_dt:
if timezone.is_naive(lastmod_dt):
lastmod_dt = timezone.make_aware(lastmod_dt)
lastmod_str = lastmod_dt.isoformat()
else:
lastmod_str = None
# Construct URL metadata dictionary
url_info = {
"item": item,
"location": item.share_link,
"lastmod": lastmod_str,
"changefreq": self.changefreq,
"priority": str(self.priority),
"alternates": alternates,
}
urls.append(url_info)
return urls

8
apps/video/views.py

@ -8,6 +8,7 @@ from rest_framework.viewsets import ModelViewSet
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from apps.library.pagination import NoPagination
from utils.mixins import CanonicalSlugViewSetMixin, Gone410ViewMixin
from utils.pagination import StandardResultsSetPagination
@ -413,12 +414,15 @@ class VideoPlaylistDetailAPIView(generics.RetrieveAPIView):
return Response(serializer.data)
class VideoDetailAPIView(generics.RetrieveAPIView):
class VideoDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics.RetrieveAPIView):
serializer_class = VideoDetailSerializer
permission_classes = (IsAuthenticated,)
authentication_classes = [TokenAuthentication]
lookup_field = 'slug'
active_field_name = 'status'
active_expected_value = True
@swagger_auto_schema(
operation_description="Get video details by slug",
tags=["Dobodbi - Video"],
@ -433,7 +437,7 @@ class VideoDetailAPIView(generics.RetrieveAPIView):
return super().get(request, *args, **kwargs)
def get_queryset(self):
return Video.objects.filter(slug = self.kwargs.get('slug'))
return Video.objects.all()
# def retrieve(self, request, *args, **kwargs):
# instance = self.get_object()

32
config/middleware/site_middleware.py

@ -8,6 +8,11 @@ URLconf (URL configuration) for each site:
"""
from django.http.response import HttpResponseRedirectBase
from django.middleware.common import CommonMiddleware
from django.utils.http import escape_leading_slashes
class SiteMiddleware:
"""
Middleware to route requests to different URL configurations based on domain.
@ -23,3 +28,30 @@ class SiteMiddleware:
# Force Dovoodi URL configuration for all domains
request.urlconf = 'config.urls_dovoodi'
return self.get_response(request)
class HttpResponsePermanentRedirect308(HttpResponseRedirectBase):
"""
Custom response class for HTTP 308 Permanent Redirect.
This preserves the original HTTP method (e.g. POST) and payload.
"""
status_code = 308
class CommonMiddleware308(CommonMiddleware):
"""
Custom CommonMiddleware that performs trailing slash redirects
using HTTP 308 instead of HTTP 301.
"""
response_redirect_class = HttpResponsePermanentRedirect308
def get_full_path_with_slash(self, request):
"""
Return the full path of the request with a trailing slash appended.
Overridden to bypass the settings.DEBUG RuntimeError check. Since we are
redirecting with an HTTP 308 status code, the client will preserve the HTTP
method and payload, meaning redirecting POST/PUT/PATCH requests is safe.
"""
new_path = request.get_full_path(force_append_slash=True)
return escape_leading_slashes(new_path)

4
config/settings/base.py

@ -91,6 +91,7 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize', # Added for humanize template tags
'django.contrib.sitemaps',
*THIRD_PARTY_APPS,
*LOCAL_APPS,
@ -122,7 +123,7 @@ MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'config.middleware.site_middleware.CommonMiddleware308',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
# "django.contrib.auth.middleware.LoginRequiredMiddleware",
@ -133,6 +134,7 @@ MIDDLEWARE = [
'apps.account.middleware.admin_access.AdminAccessMiddleware',
]
ROOT_URLCONF = 'config.urls'
APPEND_SLASH = True
TEMPLATES = [
{

17
config/urls.py

@ -36,6 +36,21 @@ from drf_yasg import openapi
from rest_framework import permissions
import requests
from filer import views
from django.contrib.sitemaps.views import index as sitemap_index, sitemap as sitemap_view
from apps.article.sitemaps import ArticleSitemap
from apps.hadis.sitemaps import HadisSitemap
from apps.library.sitemaps import BookSitemap
from apps.podcast.sitemaps import PodcastSitemap
from apps.video.sitemaps import VideoSitemap
# Registered sitemaps for indexing
sitemaps = {
'articles': ArticleSitemap,
'hadiths': HadisSitemap,
'books': BookSitemap,
'podcasts': PodcastSitemap,
'videos': VideoSitemap,
}
# Import custom API views
from apps.api.views import CustomAPIDocumentationView, CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth
@ -103,6 +118,8 @@ urlpatterns = [
path('admin/filer/', include('filer.urls')),
path('filer/', include('filer.urls')),
path('sentry-debug/', trigger_error),
path('sitemap.xml', sitemap_index, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.index'),
path('sitemap-<section>.xml', sitemap_view, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
]
# Protected swagger URL patterns (to be used in domain-specific configs)

16
templates/sitemap.xml

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
{% spaceless %}
{% for url in urlset %}
<url>
<loc>{{ url.location }}</loc>
{% if url.lastmod %}<lastmod>{{ url.lastmod }}</lastmod>{% endif %}
{% if url.changefreq %}<changefreq>{{ url.changefreq }}</changefreq>{% endif %}
{% if url.priority %}<priority>{{ url.priority }}</priority>{% endif %}
{% for alternate in url.alternates %}
<xhtml:link rel="alternate" hreflang="{{ alternate.lang_code }}" href="{{ alternate.location }}"/>
{% endfor %}
</url>
{% endfor %}
{% endspaceless %}
</urlset>

6
utils/exceptions.py

@ -153,3 +153,9 @@ class ServiceUnavailableException(APIException):
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
default_detail = 'Service temporarily unavailable'
default_code = 'service_unavailable'
class ResourceGone(APIException):
status_code = status.HTTP_410_GONE
default_detail = "The requested resource is no longer available."
default_code = "resource_gone"

96
utils/mixins.py

@ -40,3 +40,99 @@ class ColorPaletteMixin(models.Model):
@property
def main_color_code(self):
return self.COLOR_PALETTE.get(self.color, '#000000')
from django.utils.text import slugify
from django.http import HttpResponsePermanentRedirect
from django.urls import reverse
class LowercaseSlugMixin(models.Model):
"""
Mixin to automatically generate a lowercase, unicode-supporting unique slug
from a source field (default: 'title') on save.
"""
slug_source_field = 'title'
class Meta:
abstract = True
def save(self, *args, **kwargs):
# 1. Generate slug if empty
if not self.slug:
source_value = getattr(self, self.slug_source_field, '') or ''
base_slug = slugify(source_value, allow_unicode=True) or 'slug'
base_slug = base_slug.lower()
# Ensure uniqueness
slug = base_slug
counter = 1
model_class = self.__class__
# Exclude current instance from conflict check if it exists in database
queryset = model_class.objects.all()
if self.pk:
queryset = queryset.exclude(pk=self.pk)
while queryset.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
else:
# 2. Force existing slug to lowercase (handling both string and JSONField lists)
if isinstance(self.slug, str):
self.slug = self.slug.lower()
elif isinstance(self.slug, list):
for item in self.slug:
if isinstance(item, dict) and "title" in item and item["title"]:
item["title"] = str(item["title"]).lower()
super().save(*args, **kwargs)
class CanonicalSlugViewSetMixin:
"""
Mixin for DRF Views/ViewSets to enforce lowercase slugs and redirect with 301.
"""
def dispatch(self, request, *args, **kwargs):
if request.method == 'GET':
slug = kwargs.get('slug')
if slug and any(char.isupper() for char in slug):
resolved = request.resolver_match
new_kwargs = {**resolved.kwargs, 'slug': slug.lower()}
# Reconstruct path using reverse
new_path = reverse(resolved.view_name, args=resolved.args, kwargs=new_kwargs)
# Ensure trailing slash
if not new_path.endswith('/'):
new_path += '/'
# Append query parameters if any
query_string = request.META.get('QUERY_STRING')
if query_string:
new_path = f"{new_path}?{query_string}"
return HttpResponsePermanentRedirect(new_path)
return super().dispatch(request, *args, **kwargs)
from utils.exceptions import ResourceGone
class Gone410ViewMixin:
"""
Mixin for DRF detail Views/ViewSets to return 410 Gone instead of 404
for inactive/soft-deleted records.
"""
active_field_name = 'status'
active_expected_value = True
def get_object(self):
obj = super().get_object()
active_val = getattr(obj, self.active_field_name, None)
if active_val != self.active_expected_value:
raise ResourceGone()
return obj
Loading…
Cancel
Save