Building a blog in Django

We launched the Datasette Cloud blog today. The Datasette Cloud site itself is a Django app - it uses Django and PostgreSQL to manage accounts, teams and soon billing and payments, then launches dedicated containers running Datasette for each customer.

It's been a while since I've built a new blog implementation in Django! I decided to make notes for the next time.

Features

Here are the features I consider to be essential for a blog in 2023 (though they haven't changed much in over a decade):

The models

Here's the Django model for the blog (I generated the first version of this with ChatGPT, then iterated on it):

from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
from django.utils.html import strip_tags
import markdown
from django.utils.html import mark_safe


class Tag(models.Model):
    name = models.CharField(max_length=50)
    slug = models.SlugField()

    def __str__(self):
        return self.name


class Entry(models.Model):
    title = models.CharField(max_length=200)
    created = models.DateTimeField(default=timezone.now)
    slug = models.SlugField()
    summary = models.TextField()
    body = models.TextField()
    card_image = models.URLField(
        blank=True, null=True, help_text="URL to image for social media cards"
    )
    authors = models.ManyToManyField(User, through="Authorship")
    tags = models.ManyToManyField(Tag, blank=True)

    is_draft = models.BooleanField(
        default=False,
        help_text="Draft entries do not show in index pages but can be visited directly if you know the URL",
    )

    class Meta:
        verbose_name_plural = "entries"

    @property
    def summary_rendered(self):
        return mark_safe(markdown.markdown(self.summary, output_format="html5"))

    @property
    def summary_text(self):
        return strip_tags(markdown.markdown(self.summary, output_format="html5"))

    @property
    def body_rendered(self):
        return mark_safe(markdown.markdown(self.body, output_format="html5"))

    def get_absolute_url(self):
        return "/blog/%d/%s/" % (self.created.year, self.slug)

    def __str__(self):
        return self.title


class Authorship(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    entry = models.ForeignKey(Entry, on_delete=models.CASCADE)
    order = models.PositiveIntegerField(default=0)

    class Meta:
        ordering = ["order"]

It's pretty self-explanatory. The most interesting features are the is_draft flag and the way it provides .summary_rendered and .body_rendered properties that return Markdown rendered as HTML.

The URL format for this blog is /blog/2023/welcome/ - in my experience name-spacing posts by year makes the most sense, since even the most active blogs usually only have a few posts every month.

The views

Here are the view functions defined the views.py module for my blog/ application:

from django.contrib.syndication.views import Feed
from django.shortcuts import render, get_object_or_404
from django.utils.feedgenerator import Atom1Feed
from .models import Entry, Tag

ENTRIES_ON_HOMEPAGE = 5


def index(request):
    entries = list(
        Entry.objects.filter(is_draft=False).order_by("-created")[
            : ENTRIES_ON_HOMEPAGE + 1
        ]
    )
    has_more = False
    if len(entries) > ENTRIES_ON_HOMEPAGE:
        has_more = True
        entries = entries[:ENTRIES_ON_HOMEPAGE]
    return render(
        request, "blog/index.html", {"entries": entries, "has_more": has_more}
    )


def entry(request, year, slug):
    entry = get_object_or_404(Entry, created__year=year, slug=slug)
    return render(
        request,
        "blog/entry.html",
        {"entry": entry},
    )


def year(request, year):
    entries = Entry.objects.filter(created__year=year, is_draft=False).order_by(
        "-created"
    )
    return render(request, "blog/year.html", {"entries": entries, "year": year})


def archive(request):
    entries = Entry.objects.filter(is_draft=False).order_by("-created")
    return render(request, "blog/archive.html", {"entries": entries})


def tag(request, slug):
    tag = Tag.objects.get(slug=slug)
    entries = tag.entry_set.filter(is_draft=False).order_by("-created")
    return render(request, "blog/tag.html", {"tag": tag, "entries": entries})

The Atom feed

The most interesting part of the views.py file is this bit - defining the Atom feed:

class BlogFeed(Feed):
    title = "Datasette Cloud"
    link = "/blog/"
    feed_type = Atom1Feed

    def items(self):
        return Entry.objects.filter(is_draft=False).order_by("-created")[:5]

    def item_title(self, item):
        return item.title

    def item_description(self, item):
        return item.summary_rendered + "\n" + item.body_rendered

    def item_link(self, item):
        return "/blog/%d/%s/" % (item.created.year, item.slug)

    def item_author_name(self, item):
        return (
            ", ".join([a.get_full_name() or str(a) for a in item.authors.all()]) or None
        )

    def get_feed(self, obj, request):
        feedgen = super().get_feed(obj, request)
        feedgen.content_type = "application/xml; charset=utf-8"
        return feedgen

This is using the Django syndication feed framework. The resulting Atom feed can be found here:

https://www.datasette.cloud/blog/feed/

There's one extra trick here: I'm over-riding the default content-type header and setting it to "application/xml; charset=utf-8.

Django defaults to using application/atom+xml; charset=utf-8 which is correct... but causes most browsers to trigger a download rather than rendering the XML in the browser directly.

I like to be able to click on a feed link and see the XML before I paste the URL into my feed reader software, so I prefer to use application/xml instead.

Social media cards

It's easy to forget these, but they're really important - with the right markup links to posts shared on Mastodon, Twitter, LinkedIn and Facebook will look MUCH better.

Here's a snipet from my entry.html template:

{% block extra_head %}
{% if entry.card_image %}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="{{ entry.card_image }}">
{% else %}
<meta name="twitter:card" content="summary">
{% endif %}
<meta name="twitter:creator" content="@datasetteproj">
<meta property="og:url" content="https://www.datasette.cloud{{ request.path }}">
<meta property="og:title" content="{{ entry.title }} - Datasette Cloud">
{% if entry.card_image %}<meta property="og:image" content="{{ entry.card_image }}">{% endif %}
<meta property="og:type" content="article">
<meta property="og:description" content="{{ entry.summary_text }}">
{% if entry.is_draft %}
<meta name="robots" content="noindex">
{% endif %}
{% endblock %}

There's one other detail in there: if an entry is a draft entry I serve <meta name="robots" content="noindex"> to prevent it from being accidentally indexed by search engines.

URL configuration

Here's the URL configuration from urls.py:

    # Blog
    path("blog/", blog_views.index),
    path("blog/<int:year>/<slug:slug>/", blog_views.entry),
    path("blog/archive/", blog_views.archive),
    path("blog/<int:year>/", blog_views.year),
    path("blog/tag/<slug:slug>/", blog_views.tag),
    path("blog/feed/", blog_views.BlogFeed()),

Tests

I added a quick suite of tests, mainly to check that is_draft was working correctly but also to ensure the Atom feed works.

Testing the feed was particularly important because it's at the highest risk of accidentally breaking without me noticing it - errors that affect the HTML of the blog are much more obvious.

import pytest
from datetime import datetime
from django.contrib.auth.models import User
from django.utils import timezone
from blog.models import Entry, Tag
from xml.etree import ElementTree as ET


@pytest.fixture
def client():
    from django.test import Client

    return Client()


@pytest.fixture
def five_entries():
    author = User.objects.create_user(username="author")
    all = Tag.objects.get_or_create(name="All", slug="all")[0]
    entries = []
    for i in range(5):
        i += 1
        entry = Entry.objects.create(
            title=f"Test Entry {i}",
            slug=f"test-entry-{i}",
            created=timezone.make_aware(datetime(2023, 5, i), timezone.utc),
            summary=f"This is test entry {i}",
            body=f"This is the body of test entry {i}.",
            is_draft=i == 1,
        )
        entry.authors.add(author)
        entry.tags.add(all)
        entries.append(entry)

    return entries


@pytest.mark.django_db
def test_index_page(client, five_entries):
    response = client.get("/blog/")
    html = response.content.decode("utf-8")

    # Should have five entries without a more link
    for i in range(5):
        i += 1
        if i == 1:
            # It's the draft one
            assert f"Test Entry {i}" not in html
            assert f"This is test entry {i}" not in html
        else:
            assert f"Test Entry {i}" in html
            assert f"This is test entry {i}" in html
    assert "Older entries" not in html

    # Add two more entries to get a more link
    Entry.objects.create(
        title="Test Entry 6", slug="test-entry-6", summary=".", body="."
    )
    Entry.objects.create(
        title="Test Entry 7", slug="test-entry-7", summary=".", body="."
    )
    response2 = client.get("/blog/")
    html2 = response2.content.decode("utf-8")
    assert "Older entries" in html2


@pytest.mark.django_db
def test_entry_page(client, five_entries):
    # Test a draft and a not-draft one
    draft_entry = five_entries[0]
    not_draft_entry = five_entries[1]
    for entry, should_be_draft in (
        (draft_entry, True),
        (not_draft_entry, False),
    ):
        response = client.get(f"/blog/{entry.created.year}/{entry.slug}/")
        html = response.content.decode("utf-8")

        # Check that each entry's title and body are present on their respective page
        assert entry.title in html
        assert entry.body in html

        if should_be_draft:
            assert "(draft)" in html
            assert '<meta name="robots" content="noindex">' in html
        else:
            assert "(draft)" not in html
            assert '<meta name="robots" content="noindex">' not in html


@pytest.mark.django_db
@pytest.mark.parametrize(
    "path", ("/blog/", "/blog/archive/", "/blog/2023/", "/blog/tag/all/")
)
def test_draft_entry_not_visible(client, five_entries, path):
    draft_entry = five_entries[0]
    assert draft_entry.title == "Test Entry 1"
    # It should not be on any of the pages
    response = client.get(path)
    html = response.content.decode("utf-8")
    assert draft_entry.title not in html


@pytest.mark.django_db
def test_atom_feed(client, five_entries):
    response = client.get("/blog/feed/")
    assert response.status_code == 200
    assert response["Content-Type"] == "application/xml; charset=utf-8"
    xml = response.content.decode("utf-8")
    et = ET.fromstring(xml)
    assert "<title>Datasette Cloud</title>" in xml
    expected_entries = [e for e in five_entries if not e.is_draft]
    assert len(expected_entries) == 4
    expected_entries.sort(key=lambda e: e.created, reverse=True)
    # Should have the non-draft entries
    entries = et.findall("{http://www.w3.org/2005/Atom}entry")
    assert len(entries) == 4
    for xml_entry, entry in zip(entries, expected_entries):
        assert xml_entry.find("{http://www.w3.org/2005/Atom}title").text == entry.title
        assert (
            xml_entry.find("{http://www.w3.org/2005/Atom}link").attrib["href"]
            == f"http://testserver/blog/{entry.created.year}/{entry.slug}/"
        )
        assert (
            xml_entry.find(
                "{http://www.w3.org/2005/Atom}author/{http://www.w3.org/2005/Atom}name"
            ).text
            == "author"
        )

The finished blog

Check it out at https://www.datasette.cloud/blog/

Consider the code snippets in this TIL licensed under Apache License, Version 2.0.

Created 2023-08-15T09:58:48-07:00 · Edit