From 75e04886737fb3f129e480537326a963b308b562 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 28 Nov 2024 15:46:43 +1300 Subject: [PATCH 1/3] Initial commit of sitemaps with tests and necessary refactoring --- ...st_options_alter_post_managers_and_more.py | 41 ++++ src/djpress/models/category.py | 35 +++ src/djpress/models/post.py | 72 ++++++- src/djpress/sitemaps.py | 133 ++++++++++++ tests/test_models_category.py | 50 +++++ tests/test_models_post.py | 202 +++++++++++++++++- tests/test_sitemaps.py | 102 +++++++++ 7 files changed, 633 insertions(+), 2 deletions(-) create mode 100644 src/djpress/migrations/0008_alter_post_options_alter_post_managers_and_more.py create mode 100644 src/djpress/sitemaps.py create mode 100644 tests/test_sitemaps.py diff --git a/src/djpress/migrations/0008_alter_post_options_alter_post_managers_and_more.py b/src/djpress/migrations/0008_alter_post_options_alter_post_managers_and_more.py new file mode 100644 index 0000000..429f373 --- /dev/null +++ b/src/djpress/migrations/0008_alter_post_options_alter_post_managers_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1.3 on 2024-11-27 23:23 + +import django.db.models.deletion +import django.db.models.manager +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("djpress", "0007_pluginstorage"), + ] + + operations = [ + migrations.AlterModelOptions( + name="post", + options={"default_manager_name": "admin_objects", "verbose_name": "post", "verbose_name_plural": "posts"}, + ), + migrations.AlterModelManagers( + name="post", + managers=[ + ("admin_objects", django.db.models.manager.Manager()), + ], + ), + migrations.AlterField( + model_name="post", + name="categories", + field=models.ManyToManyField(blank=True, related_name="_posts", to="djpress.category"), + ), + migrations.AlterField( + model_name="post", + name="parent", + field=models.ForeignKey( + blank=True, + limit_choices_to={"post_type": "page"}, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="_children", + to="djpress.post", + ), + ), + ] diff --git a/src/djpress/models/category.py b/src/djpress/models/category.py index 495b558..6c3615b 100644 --- a/src/djpress/models/category.py +++ b/src/djpress/models/category.py @@ -2,6 +2,8 @@ from django.core.cache import cache from django.db import IntegrityError, models, transaction +from django.db.models import Max +from django.utils import timezone from django.utils.text import slugify from djpress.conf import settings as djpress_settings @@ -52,6 +54,13 @@ def get_category_by_slug(self: "CategoryManager", slug: str) -> "Category": return category + def get_categories_with_published_posts(self) -> "Category": + """Return a queryset of categories that have published posts. + + We can use the has_posts property to include only categories with published posts. + """ + return Category.objects.filter(pk__in=[category.pk for category in self.get_queryset() if category.has_posts]) + class Category(models.Model): """Category model.""" @@ -103,3 +112,29 @@ def url(self) -> str: from djpress.url_utils import get_category_url return get_category_url(self) + + @property + def posts(self) -> models.QuerySet: + """Return only published posts.""" + return self._posts.filter( + status="published", + date__lte=timezone.now(), + ) + + @property + def has_posts(self: "Category") -> bool: + """Return True if the category has published posts.""" + return self.posts.exists() + + @property + def last_modified(self: "Category") -> None | timezone.datetime: + """Return the most recent last modified date of posts in the category. + + This property is used in the sitemap to determine the last modified date of the category. + + If the category has no published posts, we return None. + + Returns: + None | timezone.datetime: The most recent last modified date of posts in the category. + """ + return self.posts.aggregate(latest=Max("modified_date"))["latest"] diff --git a/src/djpress/models/post.py b/src/djpress/models/post.py index 6d7aa48..6f4fdfd 100644 --- a/src/djpress/models/post.py +++ b/src/djpress/models/post.py @@ -6,6 +6,7 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Max from django.utils import timezone from django.utils.text import slugify @@ -341,6 +342,75 @@ def get_published_posts_by_author( """ return self.get_published_posts().filter(author=author) + def get_years(self) -> models.QuerySet: + """Return a list of years that have published posts. + + Returns: + list[int]: A distinct list of years. + """ + return self.dates("date", "year") + + def get_months(self, year: int) -> models.QuerySet: + """Return a list of months for a given year that have published posts. + + Args: + year (int): The year. + + Returns: + list[int]: A distinct list of months. + """ + return self.filter(date__year=year).dates("date", "month") + + def get_days(self, year: int, month: int) -> models.QuerySet: + """Return a list of days for a given year and month that have published posts. + + Args: + year (int): The year. + month (int): The month. + + Returns: + list[int]: A distinct list of days. + """ + return self.filter(date__year=year, date__month=month).dates("date", "day") + + def get_year_last_modified(self, year: int) -> timezone.datetime | None: + """Return the most recent modified_date of posts for a given year. + + Args: + year (int): The year. + + Returns: + timezone.datetime | None: The last published post for the given year. + """ + return self.filter(date__year=year).aggregate(latest=Max("modified_date"))["latest"] + + def get_month_last_modified(self, year: int, month: int) -> timezone.datetime | None: + """Return the most recent modified_date of posts for a given month. + + Args: + year (int): The year. + month (int): The month. + + Returns: + timezone.datetime | None: The last published post for the given month. + """ + return self.filter(date__year=year, date__month=month).aggregate(latest=Max("modified_date"))["latest"] + + def get_day_last_modified(self, year: int, month: int, day: int) -> timezone.datetime | None: + """Return the most recent modified_date of posts for a given day. + + Args: + year (int): The year. + month (int): The month. + day (int): The day. + + Returns: + timezone.datetime | None: The last published post for the given day. + """ + return self.filter(date__year=year, date__month=month, date__day=day).aggregate(latest=Max("modified_date"))[ + "latest" + ] + class Post(models.Model): """Post model.""" @@ -356,7 +426,7 @@ class Post(models.Model): modified_date = models.DateTimeField(auto_now=True) status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="draft") post_type = models.CharField(max_length=10, choices=CONTENT_TYPE_CHOICES, default="post") - categories = models.ManyToManyField(Category, blank=True) + categories = models.ManyToManyField(Category, blank=True, related_name="_posts") menu_order = models.IntegerField(default=0) parent = models.ForeignKey( "self", diff --git a/src/djpress/sitemaps.py b/src/djpress/sitemaps.py new file mode 100644 index 0000000..78eb662 --- /dev/null +++ b/src/djpress/sitemaps.py @@ -0,0 +1,133 @@ +"""Sitemap classes for DJ Press.""" + +from datetime import datetime +from typing import Any + +from django.contrib.sitemaps import Sitemap +from django.db.models import QuerySet + +from djpress.conf import settings as djpress_settings +from djpress.models import Category, Post + + +class PostSitemap(Sitemap): + """Sitemap for blog posts.""" + + changefreq = "monthly" + protocol = "https" + + def items(self) -> QuerySet: + """Return all published posts.""" + return Post.post_objects.all() + + def lastmod(self, obj: Post) -> datetime: + """Return the last modified date of the post.""" + return obj.modified_date + + def location(self, obj: Post) -> str: + """Return the URL of the post.""" + return obj.url + + +class PageSitemap(Sitemap): + """Sitemap for static pages.""" + + changefreq = "monthly" + protocol = "https" + + def items(self) -> QuerySet: + """Return all published pages.""" + return Post.page_objects.get_published_pages() + + def lastmod(self, obj: Post) -> datetime: + """Return the last modified date of the page.""" + return obj.modified_date + + def location(self, obj: Post) -> str: + """Return the URL of the page.""" + return obj.url + + +class CategorySitemap(Sitemap): + """Sitemap for category archives.""" + + changefreq = "daily" + protocol = "https" + + def items(self) -> QuerySet: + """Return all categories that have published posts.""" + return Category.objects.get_categories_with_published_posts() + + def lastmod(self, obj: Category) -> datetime | None: + """Return the last modified date of the most recent post in the category.""" + return obj.last_modified + + def location(self, obj: Category) -> str: + """Return the URL of the category.""" + return obj.url + + +class DateBasedSitemap(Sitemap): + """Sitemap for date-based archives.""" + + changefreq = "daily" + protocol = "https" + + def items(self) -> list[dict[str, Any]]: + """Return all date-based archives that have posts. + + Returns a list of dictionaries containing: + - year: The year + - month: The month (optional) + - day: The day (optional) + - latest_modified: The latest modified date of posts in this period + """ + if djpress_settings.ARCHIVE_ENABLED is False: + return [] + + archives: list[dict] = [] + + # Get all unique years + years = Post.post_objects.get_years() + for year in years: + archives.append( + { + "year": year.year, + "latest_modified": Post.post_objects.get_year_last_modified(year.year), + }, + ) + + # Get all months in this year + months = Post.post_objects.get_months(year.year) + for month in months: + archives.append( + { + "year": year.year, + "month": month.month, + "latest_modified": Post.post_objects.get_month_last_modified(year.year, month.month), + }, + ) + + # Get all days in this month + days = Post.post_objects.get_days(year.year, month.month) + for day in days: + archives.append( # noqa: PERF401 + { + "year": year.year, + "month": month.month, + "day": day.day, + "latest_modified": Post.post_objects.get_day_last_modified(year.year, month.month, day.day), + }, + ) + + return archives + + def lastmod(self, obj: dict[str, Any]) -> datetime: + """Return the last modified date of posts in this archive.""" + return obj["latest_modified"] + + def location(self, obj: dict[str, Any]) -> str: + """Return the URL of the archive.""" + from djpress.url_utils import get_archives_url + + return get_archives_url(obj["year"], obj.get("month"), obj.get("day")) diff --git a/tests/test_models_category.py b/tests/test_models_category.py index df077d1..026ffc6 100644 --- a/tests/test_models_category.py +++ b/tests/test_models_category.py @@ -2,6 +2,7 @@ from djpress.models import Category from django.utils.text import slugify +from django.utils import timezone @pytest.mark.django_db @@ -169,3 +170,52 @@ def test_category_permalink(settings): settings.DJPRESS_SETTINGS["CATEGORY_PREFIX"] = "" assert category.permalink == "test-category" + + +@pytest.mark.django_db +def test_category_posts(test_post1, test_post2, category1, category2): + assert list(category1.posts.all()) == [test_post1] + assert list(category2.posts.all()) == [test_post2] + + test_post2.categories.set([category1]) + test_post2.save() + assert list(category1.posts.all()) == [test_post1, test_post2] + assert list(category2.posts.all()) == [] + + +@pytest.mark.django_db +def test_category_has_posts(test_post1, test_post2, category1, category2): + assert category1.has_posts is True + assert category2.has_posts is True + + test_post2.categories.set([category1]) + test_post2.save() + assert category1.has_posts is True + assert category2.has_posts is False + + +@pytest.mark.django_db +def test_get_category_published(test_post1, test_post2, category1, category2): + assert list(Category.objects.get_categories_with_published_posts()) == [category1, category2] + + test_post1.status = "draft" + test_post1.save() + assert list(Category.objects.get_categories_with_published_posts()) == [category2] + + test_post2.date = timezone.now() + timezone.timedelta(days=1) + test_post2.save() + assert list(Category.objects.get_categories_with_published_posts()) == [] + + +@pytest.mark.django_db +def test_category_last_modified(test_post1, test_post2, category1, category2): + assert category1.last_modified == test_post1.modified_date + assert category2.last_modified == test_post2.modified_date + + test_post1.modified_date = timezone.now() + timezone.timedelta(days=1) + test_post1.save() + assert category1.last_modified == test_post1.modified_date + + test_post1.status = "draft" + test_post1.save() + assert category1.last_modified is None diff --git a/tests/test_models_post.py b/tests/test_models_post.py index 9669f68..a372433 100644 --- a/tests/test_models_post.py +++ b/tests/test_models_post.py @@ -1,10 +1,11 @@ import pytest from django.utils import timezone from unittest.mock import Mock -from djpress.models import Category, Post from django.core.cache import cache from django.core.exceptions import ValidationError +from django.db.models import QuerySet +from djpress.models import Category, Post from djpress import urls as djpress_urls from djpress.models.post import PUBLISHED_POSTS_CACHE_KEY from djpress.exceptions import PostNotFoundError, PageNotFoundError @@ -22,6 +23,20 @@ def test_post_model(test_post1, user, category1): assert str(test_post1) == "Test Post1" +@pytest.mark.django_db +def test_post_default_queryset(test_post1, test_post2, test_post3): + """Make sure the default queryset returns only published posts.""" + assert list(Post.objects.all()) == [test_post1, test_post2, test_post3] + + test_post1.status = "draft" + test_post1.save() + assert list(Post.objects.all()) == [test_post2, test_post3] + + test_post2.date = timezone.now() + timezone.timedelta(days=1) + test_post2.save() + assert list(Post.objects.all()) == [test_post3] + + @pytest.mark.django_db def test_get_published_content_with_future_date(user): Post.post_objects.create( @@ -1126,3 +1141,188 @@ def test_post_parent_is_none(test_post1, test_page1): test_post1.save() assert test_post1.parent is None + + +@pytest.mark.django_db +def test_post_get_years(test_post1, test_post2, test_post3): + # type should be a queryset + assert isinstance(Post.post_objects.get_years(), QuerySet) + # Queryset should have 1 item + assert len(Post.post_objects.get_years()) == 1 + # The item should be the year of the post + assert Post.post_objects.get_years()[0].year == test_post1.date.year + + test_post2.date = timezone.make_aware(timezone.datetime(2023, 1, 1, 12, 0, 0)) + test_post2.save() + + # Queryset should have 2 items + assert len(Post.post_objects.get_years()) == 2 + # The items should be the years of the posts + assert Post.post_objects.get_years()[0].year == test_post2.date.year + assert Post.post_objects.get_years()[1].year == test_post1.date.year + + test_post3.date = timezone.make_aware(timezone.datetime(2022, 1, 1, 12, 0, 0)) + test_post3.save() + + # Queryset should have 3 items + assert len(Post.post_objects.get_years()) == 3 + # The items should be the years of the posts + assert Post.post_objects.get_years()[0].year == test_post3.date.year + assert Post.post_objects.get_years()[1].year == test_post2.date.year + assert Post.post_objects.get_years()[2].year == test_post1.date.year + + # Change a post to draft status + test_post1.status = "draft" + test_post1.save() + + # Queryset should have 2 items + assert len(Post.post_objects.get_years()) == 2 + # The items should be the years of the posts + assert Post.post_objects.get_years()[0].year == test_post3.date.year + assert Post.post_objects.get_years()[1].year == test_post2.date.year + + +@pytest.mark.django_db +def test_post_get_months(test_post1, test_post2, test_post3): + months = Post.post_objects.get_months(test_post1.date.year) + + # type should be a queryset + assert isinstance(months, QuerySet) + # Queryset should have 1 item - all three posts are in the same year and month + assert len(months) == 1 + # The item should be the month of the post + assert months[0].month == test_post1.date.month + + # Set specific dates for each of the posts + test_post1.date = timezone.make_aware(timezone.datetime(2022, 1, 1, 12, 0, 0)) + test_post1.save() + test_post2.date = timezone.make_aware(timezone.datetime(2022, 2, 1, 12, 0, 0)) + test_post2.save() + test_post3.date = timezone.make_aware(timezone.datetime(2022, 3, 1, 12, 0, 0)) + test_post3.save() + + months = Post.post_objects.get_months(test_post1.date.year) + + # Queryset should have 3 items + assert len(months) == 3 + # The items should be the months of the posts + assert months[0].month == test_post1.date.month + assert months[1].month == test_post2.date.month + assert months[2].month == test_post3.date.month + + # Change a post to draft status + test_post1.status = "draft" + test_post1.save() + + months = Post.post_objects.get_months(test_post1.date.year) + + # Queryset should have 2 items + assert len(months) == 2 + # The items should be the months of the posts + assert months[0].month == test_post2.date.month + assert months[1].month == test_post3.date.month + + +@pytest.mark.django_db +def test_post_get_days(test_post1, test_post2, test_post3): + days = Post.post_objects.get_days(test_post1.date.year, test_post1.date.month) + + # type should be a queryset + assert isinstance(days, QuerySet) + # Queryset should have 1 item - all three posts are in the same year and month + assert len(days) == 1 + + # Set specific dates for each of the posts + test_post1.date = timezone.make_aware(timezone.datetime(2022, 1, 1, 12, 0, 0)) + test_post1.save() + test_post2.date = timezone.make_aware(timezone.datetime(2022, 1, 2, 12, 0, 0)) + test_post2.save() + test_post3.date = timezone.make_aware(timezone.datetime(2022, 1, 3, 12, 0, 0)) + test_post3.save() + + days = Post.post_objects.get_days(test_post1.date.year, test_post1.date.month) + + # Queryset should have 3 items + assert len(days) == 3 + # The items should be the days of the posts + assert days[0].day == test_post1.date.day + assert days[1].day == test_post2.date.day + assert days[2].day == test_post3.date.day + + # Change a post to draft status + test_post1.status = "draft" + test_post1.save() + + days = Post.post_objects.get_days(test_post1.date.year, test_post1.date.month) + + # Queryset should have 2 items + assert len(days) == 2 + # The items should be the days of the posts + assert days[0].day == test_post2.date.day + assert days[1].day == test_post3.date.day + + +@pytest.mark.django_db +def test_get_year_last_modified(test_post1, test_post2, test_post3): + # Should match the modified date of the last post in the list - i.e. most recent post + assert Post.post_objects.get_year_last_modified(test_post1.date.year) == test_post3.modified_date + + # Change test_post3 to draft and it should now match test_post2 + test_post3.status = "draft" + test_post3.save() + assert Post.post_objects.get_year_last_modified(test_post1.date.year) == test_post2.modified_date + + # Changetest_post2 to future date and it should now match test_post1 + test_post2.date = timezone.now() + timezone.timedelta(days=1) + test_post2.save() + assert Post.post_objects.get_year_last_modified(test_post1.date.year) == test_post1.modified_date + + +@pytest.mark.django_db +def test_get_month_last_modified(test_post1, test_post2, test_post3): + # Should match the modified date of the last post in the list - i.e. most recent post + assert ( + Post.post_objects.get_month_last_modified(test_post1.date.year, test_post1.date.month) + == test_post3.modified_date + ) + + # Change test_post3 to draft and it should now match test_post2 + test_post3.status = "draft" + test_post3.save() + assert ( + Post.post_objects.get_month_last_modified(test_post1.date.year, test_post1.date.month) + == test_post2.modified_date + ) + + # Changetest_post2 to future date and it should now match test_post1 + test_post2.date = timezone.now() + timezone.timedelta(days=1) + test_post2.save() + assert ( + Post.post_objects.get_month_last_modified(test_post1.date.year, test_post1.date.month) + == test_post1.modified_date + ) + + +@pytest.mark.django_db +def test_get_day_last_modified(test_post1, test_post2, test_post3): + # Should match the modified date of the last post in the list - i.e. most recent post + assert ( + Post.post_objects.get_day_last_modified(test_post1.date.year, test_post1.date.month, test_post1.date.day) + == test_post3.modified_date + ) + + # Change test_post3 to draft and it should now match test_post2 + test_post3.status = "draft" + test_post3.save() + assert ( + Post.post_objects.get_day_last_modified(test_post1.date.year, test_post1.date.month, test_post1.date.day) + == test_post2.modified_date + ) + + # Changetest_post2 to future date and it should now match test_post1 + test_post2.date = timezone.now() + timezone.timedelta(days=1) + test_post2.save() + assert ( + Post.post_objects.get_day_last_modified(test_post1.date.year, test_post1.date.month, test_post1.date.day) + == test_post1.modified_date + ) diff --git a/tests/test_sitemaps.py b/tests/test_sitemaps.py new file mode 100644 index 0000000..2395c92 --- /dev/null +++ b/tests/test_sitemaps.py @@ -0,0 +1,102 @@ +import pytest + +from djpress.sitemaps import DateBasedSitemap, PostSitemap, PageSitemap, CategorySitemap +from djpress.url_utils import get_archives_url, get_category_url +from djpress.models import Post + + +@pytest.mark.django_db +def test_post_sitemap(test_post1, test_post2, test_post3): + """Test the PostSitemap class.""" + + expected_items = [test_post1, test_post2, test_post3] + + sitemap = PostSitemap() + + assert sitemap.changefreq == "monthly" + assert sitemap.protocol == "https" + assert len(sitemap.items()) == len(expected_items) + assert sitemap.lastmod(test_post1) == test_post1.modified_date + assert sitemap.location(test_post1) == test_post1.url + assert sitemap.lastmod(test_post2) == test_post2.modified_date + assert sitemap.location(test_post2) == test_post2.url + assert sitemap.lastmod(test_post3) == test_post3.modified_date + assert sitemap.location(test_post3) == test_post3.url + + +@pytest.mark.django_db +def test_page_sitemap(test_page1, test_page2, test_page3): + """Test the PageSitemap class.""" + + expected_items = [test_page1, test_page2, test_page3] + + sitemap = PageSitemap() + + assert sitemap.changefreq == "monthly" + assert sitemap.protocol == "https" + assert len(sitemap.items()) == len(expected_items) + assert sitemap.lastmod(test_page1) == test_page1.modified_date + assert sitemap.location(test_page1) == test_page1.url + assert sitemap.lastmod(test_page2) == test_page2.modified_date + assert sitemap.location(test_page2) == test_page2.url + assert sitemap.lastmod(test_page3) == test_page3.modified_date + assert sitemap.location(test_page3) == test_page3.url + + +@pytest.mark.django_db +def test_category_sitemap(category1, category2, test_post1, test_post2): + """Test the CategorySitemap class.""" + + expected_items = [category1, category2] + + sitemap = CategorySitemap() + + assert sitemap.changefreq == "daily" + assert sitemap.protocol == "https" + assert len(sitemap.items()) == len(expected_items) + assert sitemap.lastmod(category1) == test_post1.modified_date + assert sitemap.lastmod(category2) == test_post2.modified_date + assert sitemap.location(category1) == get_category_url(category1) + + +@pytest.mark.django_db +def test_date_based_sitemap(test_post1, test_post2, test_post3): + """Test the DateBasedSitemap class.""" + + expected_items = [ + {"year": test_post3.date.year, "latest_modified": test_post3.modified_date}, + {"year": test_post3.date.year, "month": test_post3.date.month, "latest_modified": test_post3.modified_date}, + { + "year": test_post3.date.year, + "month": test_post3.date.month, + "day": test_post3.date.day, + "latest_modified": test_post3.modified_date, + }, + ] + + sitemap = DateBasedSitemap() + + assert sitemap.changefreq == "daily" + assert sitemap.protocol == "https" + assert sitemap.items() == expected_items + assert sitemap.lastmod(expected_items[0]) == test_post3.modified_date + assert sitemap.location(expected_items[0]) == get_archives_url(test_post3.date.year) + assert sitemap.location(expected_items[1]) == get_archives_url(test_post3.date.year, test_post3.date.month) + assert sitemap.location(expected_items[2]) == get_archives_url( + test_post3.date.year, test_post3.date.month, test_post3.date.day + ) + + +@pytest.mark.django_db +def test_date_based_sitemap_archives_disabled(settings, test_post1, test_post2, test_post3): + # Check that we have three published posts + assert Post.post_objects.count() == 3 + + # Disable the archives + settings.DJPRESS_SETTINGS["ARCHIVE_ENABLED"] = False + + # Create a new sitemap + sitemap = DateBasedSitemap() + + # Check that the items are empty + assert sitemap.items() == [] From 12a0e981d0e31233da82abbc15e50d1973369210 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 28 Nov 2024 16:21:28 +1300 Subject: [PATCH 2/3] Implement sitemaps in the example site --- example/config/settings.py | 1 + example/config/urls.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/example/config/settings.py b/example/config/settings.py index 1f6e5c4..f27f655 100644 --- a/example/config/settings.py +++ b/example/config/settings.py @@ -16,6 +16,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.contrib.sitemaps", "debug_toolbar", "djpress.apps.DjpressConfig", ] diff --git a/example/config/urls.py b/example/config/urls.py index b8e3df7..130e05a 100644 --- a/example/config/urls.py +++ b/example/config/urls.py @@ -1,10 +1,27 @@ """URL configuration for config project.""" from django.contrib import admin +from django.contrib.sitemaps.views import sitemap from django.urls import include, path +from djpress.sitemaps import ( + CategorySitemap, + DateBasedSitemap, + PageSitemap, + PostSitemap, +) + +# Define your sitemaps dictionary +sitemaps = { + "posts": PostSitemap, + "pages": PageSitemap, + "categories": CategorySitemap, + "archives": DateBasedSitemap, +} + urlpatterns = [ path("admin/", admin.site.urls), path("__debug__/", include("debug_toolbar.urls")), + path("sitemap.xml", sitemap, {"sitemaps": sitemaps}, name="django.contrib.sitemaps.views.sitemap"), path("", include("djpress.urls")), ] From 56e05030b2dbf025ec85f40e736e9e88f26a37cb Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Thu, 28 Nov 2024 16:21:37 +1300 Subject: [PATCH 3/3] Update docs --- docs/index.md | 2 + docs/sitemap.md | 153 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 docs/sitemap.md diff --git a/docs/index.md b/docs/index.md index 0997189..004821b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,6 +9,7 @@ complete: - [Template Tags](templatetags.md) - [Themes](themes.md) - [Plugins](plugins.md) +- [Sitemap](sitemap.md) ## Table of Contents @@ -22,4 +23,5 @@ url_structure templatetags themes plugins +sitemap ``` diff --git a/docs/sitemap.md b/docs/sitemap.md new file mode 100644 index 0000000..e9af8f0 --- /dev/null +++ b/docs/sitemap.md @@ -0,0 +1,153 @@ +# Sitemap + +DJ Press provides sitemap support for your blog content through Django's built-in sitemap framework. This allows search engines to more intelligently crawl your site by providing information about: + +- Blog posts +- Static pages +- Category archives +- Date-based archives + +## Setup + +> Note: please refer to the current, official Django documentation for up-to-date instructions on how to configure sitemaps: +> . +> The following information should be seen as a guide only and may need modifying for your specific requirements. + +1. First, ensure Django's sitemap framework is installed. Add `django.contrib.sitemaps` to your `INSTALLED_APPS`: + + ```python + INSTALLED_APPS = [ + ... + 'django.contrib.sitemaps', + ... + ] + ``` + +2. Import the DJ Press sitemap classes in your project's `urls.py`: + + ```python + from django.contrib.sitemaps.views import sitemap + from djpress.sitemaps import ( + CategorySitemap, + DateBasedSitemap, + PageSitemap, + PostSitemap, + ) + + # Define your sitemaps dictionary + sitemaps = { + 'posts': PostSitemap, + 'pages': PageSitemap, + 'categories': CategorySitemap, + 'archives': DateBasedSitemap, + } + + # Add the sitemap URL patterns + urlpatterns = [ + ... + path("sitemap.xml", sitemap, {"sitemaps": sitemaps}, name="django.contrib.sitemaps.views.sitemap"), + ... + ] + ``` + +That's it! Your site will now have a sitemap at `/sitemap.xml` that includes all your blog content. + +## What's Included + +The sitemap will include URLs for: + +- **Posts**: All published blog posts +- **Pages**: All published static pages +- **Categories**: All categories that contain published posts +- **Archives**: Date-based archives (year, month, day) that contain posts + +Each URL in the sitemap includes: + +- The location (URL) of the content +- Last modified date +- Change frequency +- Protocol (https by default) + +## Customisation + +### Disabling Sections + +You can choose which sections to include in your sitemap by only adding the desired classes to your sitemaps dictionary. For example, to only include posts and pages: + +```python +sitemaps = { + 'posts': PostSitemap, + 'pages': PageSitemap, +} +``` + +### Date-based Archives + +The date-based archives sitemap respects your DJ Press archive settings. If you have disabled archives in your DJ Press settings (`ARCHIVE_ENABLED = False`), the archive URLs will not be included in the sitemap. + +### Protocol + +By default, all URLs in the sitemap use the HTTPS protocol. To change this, subclass any of the sitemap classes: + +```python +from djpress.sitemaps import PostSitemap + +class CustomPostSitemap(PostSitemap): + protocol = 'http' + +sitemaps = { + 'posts': CustomPostSitemap, + ... +} +``` + +### Change Frequencies + +The default change frequencies are: + +- Posts: monthly +- Pages: monthly +- Categories: daily +- Archives: daily + +To customize these, subclass the relevant sitemap class: + +```python +from djpress.sitemaps import PostSitemap + +class CustomPostSitemap(PostSitemap): + changefreq = 'weekly' + +sitemaps = { + 'posts': CustomPostSitemap, + ... +} +``` + +## Performance + +The sitemap classes are designed to work efficiently with Django's ORM and respect DJ Press's caching settings. However, for sites with many posts, you may want to consider implementing caching for the sitemap views. + +Caching can be complex to implement depending on your site's set up, but as an example, to cache the sitemap, use Django's cache framework: + +```python +from django.views.decorators.cache import cache_page + +urlpatterns = [ + ... + path('sitemap.xml', cache_page(86400)(sitemap), # Cache for 24 hours + {'sitemaps': sitemaps}, + name='django.contrib.sitemaps.views.sitemap'), + ... +] +``` + +## Generating the Sitemap + +Once configured, your sitemap will be available at `/sitemap.xml`. You can verify it's working by visiting this URL in your browser or using a tool like curl: + +```bash +curl http://your-site.com/sitemap.xml +``` + +The sitemap follows the [sitemaps.org protocol](https://www.sitemaps.org/protocol.html) and can be submitted to search engines through their respective webmaster tools.