Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sitemap #85

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ complete:
- [Template Tags](templatetags.md)
- [Themes](themes.md)
- [Plugins](plugins.md)
- [Sitemap](sitemap.md)

## Table of Contents

Expand All @@ -22,4 +23,5 @@ url_structure
templatetags
themes
plugins
sitemap
```
153 changes: 153 additions & 0 deletions docs/sitemap.md
Original file line number Diff line number Diff line change
@@ -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:
> <https://docs.djangoproject.com/en/stable/ref/contrib/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.
1 change: 1 addition & 0 deletions example/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sitemaps",
"debug_toolbar",
"djpress.apps.DjpressConfig",
]
Expand Down
17 changes: 17 additions & 0 deletions example/config/urls.py
Original file line number Diff line number Diff line change
@@ -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")),
]
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
35 changes: 35 additions & 0 deletions src/djpress/models/category.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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"]
72 changes: 71 additions & 1 deletion src/djpress/models/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand All @@ -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",
Expand Down
Loading