Build a blog with Django: Display published posts

In this post, I'd show you how to display your published posts to your readers. There are many ways to go about this task but I'd show you the best practice way.

Our journey would take us through many parts of the framework. In particular, we'd touch on managers, get_absolute_url, URL patterns, views and templates.

I hope you enjoy the ride.

Let's get started.

Quick recap

This task is part of a larger feature we're adding to our blog which started here when we first created the posts app. Since then we've done the following:

  1. Added a post model, see here.

  2. Added post administration capabilities, see here.

  3. And, added a custom management command to seed posts. See here, here and here.

If you haven't been following along, then be sure to check out those posts I've linked to above.

If you have been following along, then congratulations on reaching this far. We're almost done with the feature.

Here's the commit that marks our starting point for today.

Goals

What do we want to accomplish here?

We want a page that lists all our published posts in reverse chronological order. If there are no posts then we should show an empty state.

The empty state for the list of published posts page

But, if there are posts then for each post we should display its title and excerpt.

A list of published posts showing their titles and excerpts

The title should be a link which takes you to a detail page for that post. And, on the detail page we should show its title and body content.

The detail page for a given post

The reader should NEVER be allowed to view a draft post. And, attempting to do so MUST return a 404 page.

Show a 404 page if the post is unpublished

N.B. Since DEBUG = True we don't actually see the 404 page but we do see that a 404 response was returned.

Retrieving published posts in reverse chronological order

A Django model maps to a single database table. In order to perform database query operations on that table you have to go through a manager. By default, at least one manager, named objects, exists for every model in a Django application.

Hence, Post.objects returns the default manager.

Once you have the manager you use it to construct a QuerySet. A QuerySet represents a collection of objects from your database.

The QuerySet we need is

Post.objects.filter(is_published=True).order_by('-published_at')  

i.e. all published posts in reverse chronological order.

The filter method returns a new QuerySet containing objects that match the given lookup parameters. And, the order_by method orders the results.

The above works but since we'll be needing this exact QuerySet in many places in the application we can do something to keep the code DRY.

One thing we can do is to create a custom manager and modify its initial QuerySet. Open posts/models.py and edit it to contain:

class PublishedPostManager(models.Manager):  
    def get_queryset(self):
        return super().get_queryset()               \
                      .filter(is_published=True)    \
                      .order_by('-published_at')


class Post(models.Model):  
    objects = models.Manager()
    published_objects = PublishedPostManager()

    # ...

By setting a custom manager, published_objects, the default one isn't automatically created for us. Since we do want the default objects manager as well, we have to create it for ourselves.

Now, when we do Post.published_objects.all() we get all the published posts in reverse chronological order.

We only scratched the surface of what's available.

Learn more about the concepts we covered by checking out the following links:

The posts index page

Next let's work on displaying the list of the published posts in reverse chronological order.

Within the src/posts create a URLconf called urls.py and edit it to contain:

from django.conf.urls import url

from . import views


app_name = 'posts'


urlpatterns = [  
    url(r'^$', views.index, name='index'),
]

N.B. The app_name and name values allow us to be independent of the URL structure. You'd see what I mean when we make use of it later on.

Two things are missing for this to work:

  1. A connection to the root URLconf, and
  2. The index view function.

To connect it to the root URLconf go to yaba/urls.py and update it as follows:

from django.conf.urls import include


urlpatterns = [  
    # ...
    url(r'^blog/', include('posts.urls')),
    # ...
]

Then, open posts/views.py and replace its contents with:

from django.shortcuts import get_object_or_404, render

from .models import Post


def index(request):  
    posts = Post.published_objects.all()

    return render(request, 'posts/index.html', {
        'posts': posts
    })

We're almost here.

If you navigate to http://127.0.0.1:8000/blog/ you'd get a TemplateDoesNotExist exception. Try it and see for yourself. To fix this we need to create our template and tell Django where to find it.

In the src directory create a templates folder with the following structure:

templates/  
├── posts
│   ├── detail.html
│   └── index.html
└── yaba
    ├── index.html
    └── layouts
        └── base.html

Open templates/yaba/layouts/base.html and edit it to contain:

<!DOCTYPE html>  
<html lang="en">  
  <head>
    <meta charset="utf-8">
    <title>{% block title %}Yet Another Blog{% endblock %}</title>
  </head>
  <body>
    {% block main_content %}{% endblock %}
  </body>
</html>  

Open templates/posts/index.html and edit that to contain:

{% extends "yaba/layouts/base.html" %}

{% block main_content %}
{% for post in posts %}
  <div>
    <h2><a href="#">{{ post.title }}</a></h2>
    <p>{{ post.excerpt }}</p>
  </div>
{% empty %}
  <p>No posts.</p>
{% endfor %}
{% endblock %}

Lastly, edit the TEMPLATES setting in the settings file yaba/settings.py and change the DIRS option to be as follows:

TEMPLATES = [  
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [
            os.path.join(BASE_DIR, 'templates'),
        ],
        # ...
    },
]

That's it.

Create some posts, start up the development server,

(venv) $ cd src
(venv) $ python manage.py createposts --unpublished 2 --published 5
(venv) $ python manage.py runserver

and navigate to http://127.0.0.1:8000/blog/, to see them.

N.B. Check here to learn how the custom createposts management command was implemented.

We had to know a little about a lot of different parts of the Django framework but that's how it usually unfolds as you develop a feature.

For more in-depth knowledge about any particular area we covered I'd suggest you read the following:

The post detail page

Let's get those links working.

Starting with the URLconf, add the following pattern to posts/urls.py:

urlpatterns = [  
    # ...
    url(r'^(?P<slug>[\w-]+)/$', views.detail, name='detail'),
]

The regular expression, [\w-]+, matches one or more Unicode word characters or dashes in any combination. Hence, it allows us to give our posts URLs like http://127.0.0.1:8000/blog/hello-world or http://127.0.0.1:8000/blog/learn-python-and-django.

The ?P<slug> part tells the URL dispatcher to capture the match, i.e. hello-world or learn-python-and-django, and pass it to the view function as a keyword argument named slug.

Open posts/views.py and add the detail view function:

from django.shortcuts import get_object_or_404


def detail(request, slug):  
    post = get_object_or_404(Post.published_objects, slug=slug)

    return render(request, 'posts/detail.html', {
        'post': post
    })

The get_object_or_404 function calls get on the Post.published_objects model manager and raises Http404 if the post couldn't be found. Read here to learn more.

Now, add the following to the templates/posts/detail.html template file:

{% extends "yaba/layouts/base.html" %}

{% block title %}{{post.title}} - {{ block.super }}{% endblock  %}

{% block main_content %}
<h1>{{ post.title }}</h1>  
<div>  
  {{ post.body }}
</div>  
{% endblock %}

N.B. For SEO purposes we update the title of the page to include the title of the post.

Finally, link each post to their detail page by updating the links in templates/posts/index.html.

<a href="{% url 'posts:detail' post.slug %}">{{ post.title }}</a>  

'posts' is the namespace we set when we configured app_name in posts/urls.py. And, 'detail' is the name we gave to the URL pattern that points to the detail view. Hence, posts:detail refers to said URL pattern. It requires the slug to generate the correct URL and so we give it the slug from the current post.

get_absolute_url

The get_absolute_url is an instance method you can define on a model to tell Django how to calculate the canonical URL for an instance of the model.

Django uses get_absolute_url in the admin and the syndication feed framework.

Edit posts/models.py and add the following to the existing Post model:

from django.urls import reverse

class Post(models.Model):

    def get_absolute_url(self):
        return reverse('posts:detail', kwargs={'slug': self.slug})

We use the reverse function so that we don't have to depend on the actual URL.

This allows us to refactor the link on the detail page to:

<a href="{{ post.get_absolute_url }}">{{ post.title }}</a>  

Update the home page

Finally, I took the liberty to update the home page. At this point in the app we were still using a hard-coded home page.

But, with the introduction of templates we can refactor the home page to make use of one.

Open templates/yaba/index.html and edit it to contain:

{% extends "./layouts/base.html" %}

{% block main_content %}
<p>Hello, world! Check out my <a href="{% url 'posts:index' %}">blog</a>.</p>  
{% endblock %}

Open yaba/urls.py and replace url(r'^$', views.home) with url(r'^$', views.index).

Finally, replace the contents of yaba/views.py with:

from django.shortcuts import render


def index(request):  
    return render(request, 'yaba/index.html')

Wrap up

Woo hoo! The feature is complete.

Completed

All the changes we've made today can be found here.

The pull request tracking this feature can be found here.

Next up, Markdown support.

See you then.

P.S. Let me know in the comments if you got into any problems. I'd be happy to help you resolve them.

P.S.S. Subscribe to my newsletter if you're interested in getting exclusive Django content.