From b1108ca0b48b275a4d4fe3985579a0f25a1d08c1 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Sun, 12 Jul 2026 16:00:22 +0330 Subject: [PATCH] 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. --- apps/article/models.py | 8 +-- apps/article/sitemaps.py | 100 +++++++++++++++++++++++++++ apps/article/views.py | 8 ++- apps/hadis/models/hadis.py | 10 +-- apps/hadis/models/reference.py | 10 +++ apps/hadis/sitemaps.py | 69 ++++++++++++++++++ apps/hadis/views/hadis.py | 8 ++- apps/library/models.py | 6 +- apps/library/sitemaps.py | 62 +++++++++++++++++ apps/library/views.py | 10 ++- apps/podcast/models.py | 11 ++- apps/podcast/sitemaps.py | 62 +++++++++++++++++ apps/podcast/views.py | 8 ++- apps/video/models.py | 6 +- apps/video/sitemaps.py | 62 +++++++++++++++++ apps/video/views.py | 8 ++- config/middleware/site_middleware.py | 32 +++++++++ config/settings/base.py | 4 +- config/urls.py | 17 +++++ templates/sitemap.xml | 16 +++++ utils/exceptions.py | 8 ++- utils/mixins.py | 98 +++++++++++++++++++++++++- 22 files changed, 588 insertions(+), 35 deletions(-) create mode 100644 apps/article/sitemaps.py create mode 100644 apps/hadis/sitemaps.py create mode 100644 apps/library/sitemaps.py create mode 100644 apps/podcast/sitemaps.py create mode 100644 apps/video/sitemaps.py create mode 100644 templates/sitemap.xml diff --git a/apps/article/models.py b/apps/article/models.py index 47a8fa6..2c137f9 100755 --- a/apps/article/models.py +++ b/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: diff --git a/apps/article/sitemaps.py b/apps/article/sitemaps.py new file mode 100644 index 0000000..1e53165 --- /dev/null +++ b/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 diff --git a/apps/article/views.py b/apps/article/views.py index 36846d0..2888d1d 100755 --- a/apps/article/views.py +++ b/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,11 +224,14 @@ 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", @@ -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() diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py index ecdbd05..d81d64f 100644 --- a/apps/hadis/models/hadis.py +++ b/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,11 +328,12 @@ 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 + # Reset embedded_in if text or translation changes if self.pk: old_instance = Hadis.objects.get(pk=self.pk) if (old_instance.text != self.text or diff --git a/apps/hadis/models/reference.py b/apps/hadis/models/reference.py index f151b79..93e024a 100644 --- a/apps/hadis/models/reference.py +++ b/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) diff --git a/apps/hadis/sitemaps.py b/apps/hadis/sitemaps.py new file mode 100644 index 0000000..f394142 --- /dev/null +++ b/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 diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py index 4994bcf..7a78dfa 100644 --- a/apps/hadis/views/hadis.py +++ b/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', diff --git a/apps/library/models.py b/apps/library/models.py index 6f38363..36b7085 100644 --- a/apps/library/models.py +++ b/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) diff --git a/apps/library/sitemaps.py b/apps/library/sitemaps.py new file mode 100644 index 0000000..a2f4bfd --- /dev/null +++ b/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 diff --git a/apps/library/views.py b/apps/library/views.py index 1354523..9dab81a 100644 --- a/apps/library/views.py +++ b/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) diff --git a/apps/podcast/models.py b/apps/podcast/models.py index c5d18c1..1d8cff9 100644 --- a/apps/podcast/models.py +++ b/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: diff --git a/apps/podcast/sitemaps.py b/apps/podcast/sitemaps.py new file mode 100644 index 0000000..3b87b23 --- /dev/null +++ b/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 diff --git a/apps/podcast/views.py b/apps/podcast/views.py index e57d4f9..030dd2b 100644 --- a/apps/podcast/views.py +++ b/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() diff --git a/apps/video/models.py b/apps/video/models.py index 3ea0f21..c276a2d 100644 --- a/apps/video/models.py +++ b/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) diff --git a/apps/video/sitemaps.py b/apps/video/sitemaps.py new file mode 100644 index 0000000..1ce0504 --- /dev/null +++ b/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 diff --git a/apps/video/views.py b/apps/video/views.py index 4a973be..1fbea2e 100644 --- a/apps/video/views.py +++ b/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() diff --git a/config/middleware/site_middleware.py b/config/middleware/site_middleware.py index 24cc65d..502eb9b 100644 --- a/config/middleware/site_middleware.py +++ b/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) diff --git a/config/settings/base.py b/config/settings/base.py index d7525f9..cb76f4b 100644 --- a/config/settings/base.py +++ b/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 = [ { diff --git a/config/urls.py b/config/urls.py index 7ede0fe..7ca2069 100644 --- a/config/urls.py +++ b/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-
.xml', sitemap_view, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'), ] # Protected swagger URL patterns (to be used in domain-specific configs) diff --git a/templates/sitemap.xml b/templates/sitemap.xml new file mode 100644 index 0000000..8c26502 --- /dev/null +++ b/templates/sitemap.xml @@ -0,0 +1,16 @@ + + +{% spaceless %} +{% for url in urlset %} + + {{ url.location }} + {% if url.lastmod %}{{ url.lastmod }}{% endif %} + {% if url.changefreq %}{{ url.changefreq }}{% endif %} + {% if url.priority %}{{ url.priority }}{% endif %} + {% for alternate in url.alternates %} + + {% endfor %} + +{% endfor %} +{% endspaceless %} + diff --git a/utils/exceptions.py b/utils/exceptions.py index 147defb..4476008 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -152,4 +152,10 @@ class InvaliedCodeVrify(APIException): class ServiceUnavailableException(APIException): status_code = status.HTTP_500_INTERNAL_SERVER_ERROR default_detail = 'Service temporarily unavailable' - default_code = 'service_unavailable' \ No newline at end of file + 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" \ No newline at end of file diff --git a/utils/mixins.py b/utils/mixins.py index fd3cae0..565163c 100644 --- a/utils/mixins.py +++ b/utils/mixins.py @@ -39,4 +39,100 @@ class ColorPaletteMixin(models.Model): @property def main_color_code(self): - return self.COLOR_PALETTE.get(self.color, '#000000') \ No newline at end of file + 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 \ No newline at end of file