Slug routing in Django

Django

2 April, 2020



URL routing with slugs is a lot prettier (and possibly a bit safer) than simply routing with IDs. The idea behind slugs is that they're used to generate a human-readable, more meaningful URL. For example, the URL for this post is probably going to end up as heds.nz/blog/slug-routing-in-django/. That's quite a bit better than heds.nz/blog/15/, which is what it would have been if I'd left the URLs to simply reflect the primary key of the database entry! Additionally, slugs probably provide better search engine optimization for your website. So, this tutorial will to show you how to set up a Django website from scratch, complete with working slug URLs.

The setup

To do this, we're going to create a new Django project that we'll use to make a blog website with. In our blog app we'll have a BlogPost model, for which we'll provide fields for the URL slug.

Firstly, let's create a top-level directory for our project. In the interests of keeping everything for this tutorial in one place, I'm going to create a virtual environment within this directory, as well as the directory for the Django app itself. I'm using Git Bash for this, but use whatever works for you.

$ mkdir slug_routing_in_django
$ cd slug_routing_in_django

Okay, let's create our virtual environment, and name it 'my_env'. This will create a new folder in the current directory. After it's created, activate it. (Note that this will depend on whether you're using Windows or Unix/MacOS; see here for more on virtual environments.)

$ python -m venv my_env
$ source my_env/Scripts/activate

Alright, we should have an activated virtual environment set up now; we can confirm this by checking that the terminal has the name of our virtual environment preceding the '$' sign in parentheses. Let's install Django.

(my_env) $ pip install django

Now let's create our Django project. This should create a new folder, 'website'. Once it's created, navigate to the folder and get the database up and running with migrate, then start the development server to check that it's all working.

(my_env) $ django-admin startproject website
(my_env) $ cd website
(my_env) $ python manage.py migrate
(my_env) $ python manage.py runserver

Alright, hopefully that's all working fine and you can see the default Django landing page at localhost:8000/. Stop the app with Ctrl + C. Now, we'll create our blog app.

(my_env) $ python manage.py startapp blog

Navigate to ~/slug_routing_in_django/website/website/settings.py and add the blog app to the INSTALLED_APPS setting.

INSTALLED_APPS: [
    ...
    'blog',
]

Adding the model

Okay, we're now ready to add our BlogPost model! This is going to be really simple, and consist of only a few fields. We have title, blog post content and slug fields. Note that there is a specific SlugField in Django, and we've set unique=True so that each post must have a unique slug, and null=False, so that each post requires a slug. We'll also define __str__ and get_absolute_url.

from django.db import models

class BlogPost(models.Model):
    title = models.CharField(max_length=255)
    content = models.TextField()
    slug = models.SlugField(unique=True, null=False)

    def __str__(self):
        return self.title
    
    def get_absolute_url(self):
        from django.urls import reverse
        return reverse('detail', kwargs = {'slug': self.slug})

Okay, since we've updated our model, we need to apply the migrations.

(my_env) $ python manage.py makemigrations
(my_env) $ python manage.py migrate

Creating a test post

Now that we've created our BlogPost model, we need to add a blog post! In the real world, we'd set up the Django admin app and do it from there, but in the interests of keeping this simple and light, let's just use the database API to create a couple of posts from the terminal. The official Django tutorial covers this quite nicely.

(my_env) $ python manage.py shell

Let's import the BlogPost model, make a couple of quick test posts, then save them. Note that we've provided the title, content and slug fields. Exit the shell afterwards.

>>> from blog.models import BlogPost
>>> p1 = BlogPost(title='Why dogs are better than cats', content='Do you
even fetch, bro?', slug='why-dogs-are-better-than-cats')
>>> p1.save()
>>> p2 = BlogPost(title='How to finish a Risk game in under 12 hours',
content='Just kidding', slug='how-to-finish-a-risk-game-in-under-12-hours')
>>> p2.save()
>>> exit()

URL routing

Before we can check out our cool new post, we need to do some fun URL routing. Firstly, let's go into website/website/urls.py and replace the contents with the following code. In the urlpatterns list, we tell Django that when it comes across a URL beginning with "blog", it should look in website/blog/urls.py to find the appropriate URL routing. To do this, we need to import django.urls.include. urls.py should now look like this:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path(route='admin/', view=admin.site.urls),
    path(route='blog/', view=include('blog.urls'))
]

website/blog/urls.py doesn't actually exist at the moment, so let's create it.

(my_env) $ touch blog/urls.py

All that we're going to add to the file at this stage is the following code, which tells Django that when it comes across a route of '', it should call the blog function in our view. In practise, this means that when we navigate to localhost:8000/blog/, we'll call our index view. (Remember, the project-level urls.py told us to look in blog/urls.py when it comes across a URL starting with 'blog'; below, we've given a path of '', so that's equivalent to localhost:8000/blog/.)

from django.urls import path

urlpatterns = [
    path('', views.index, name='index'),
]

Adding the views and templates

Sweet. Now we need to make our views. We've set up the routing to call the appropriate view based on the provided URL. In Django, *views* are functions (or classes), by convention stored in views.py files, that handle web requests by taking a request object and returning a response object. If we're doing things like querying our database to return a post, this is where we specify logic for that. The returned object will also specify which template to render. You can read more about views here in the official docs.

Open mywebsite/blog/views.py. In our URL routing above, we set our route of '' to route to views.blog, so we need to create a index function in this file. We're going to tell it to just grab all of the BlogPost objects in the database, and return them in the database context.

from django.shortcuts import render
from blog.models import BlogPost

def index(request):
    posts = BlogPost.objects.all()
    context = {
        "posts": posts,
    }
    return render(request, 'index.html', context)  

For now, the final piece of this particular sluggy puzzle is the template. Let's create a templates directory for the blog app, and create index.html that we called in the index function above.

(my_env) $ mkdir blog/templates
(my_env) $ touch blog/templates/index.html

Alright! Let's open this up, and add the following code. Remember that in website/blog/views.py, we defined the index request to get all the BlogPost objects in the database, and return them as posts? Well, in our template, we can access these directly using the posts variable. We're just going to grab all the BlogPost.title objects in our database context. Our new index.html should look like this:

<h1>Blog index</h1>
{% for post in posts %}
    {{ post.title }}
{% endfor %}

Let's check that all these moving parts are talking to each other okay. Run the app with (my_env) $ python manage.py runserver, and navigate to localhost:8000/blog/. We should see our 'Blog index' h1 header, and the title of our two blog posts. Awesome!

But what about the slugs!?

We're getting there. To implement the slugs, we'll need to create a view that shows the detail (that is, the content) of a blog post. Stop the dev server with Ctrl + C. Open up website/blog/views.py, and add the following view:

def detail(request, slug):
    post = BlogPost.objects.get(slug=slug)
    context = {
        "post":post,
    }
    return render(request, 'detail.html', context)

Have a close look at the view. Firstly, as well as the request, we've also asked for a slug parameter. It's telling us to check the database for the post with a slug that matches the provided slug argument. So, how do we pass our slug argument along with the request? We'll have to return to our URLs. Open website/blog/urls.py, and update it to include the following pattern, which will allow us to enter a slug into the URL path:

from django.urls import path
from . import views

urlpatterns = [
    path('', views.index, name='index'),
    path('<slug:slug>/', views.detail, name='detail'),
]

Finally, we need to make our detail.html template, and get it to show us the content of the blog post. Create the file and open it up.

(my_env) $ touch blog/templates/detail.html

We'll give it an h2 header for the title, and wrap the content in p tags. Can't get much simpler than this. Remember that the post variable is available to this template because it's been passed along by the view.

<h2>{{ post.title }}</h2>
<p>{{ post.content }}</p>

And that's it! Save the files are run (my_env) $ python manage.py runserver. Navigate to localhost:8000/blog/, and you'll see the blog index containing all (two...) posts in the database. Append a post's slug to the URL (e.g., http://localhost:8000/blog/why-dogs-are-better-than-cats/), and we'll see our detail view, complete with title and content!

Reverse URL resolution

Alright, there's a lot more to do to have a functioning website, but it's a good idea to get the URL routing done the right way from the beginning. There are some more things to think about, though.

The most obvious next step is generating links to detail.html (nobody is going to type in your blog post slugs by hand...). This is why we defined get_absolute_url in the BlogPost model, which is Django best practice. It uses reverse URL resolution. The idea is that we provide the view, as well as any arguments that are passed to it, and it generates the URL for us. This is really important in keeping with DRY (don't repeat yourself) principles (we don't want to be hardcoding our URL slugs all over the place!). Recall how we defined get_absolute_url:

def get_absolute_url(self):
    from django.urls import reverse
    return reverse('detail', kwargs = {'slug': self.slug})

When we call this function, we provide the relevant slug, and it passes it on to the detail. To see how we can implement this in our template, open up blog/templates/index.html and paste in the following code:

<h1>Blog index</h1>
{% for post in posts %}
    <a href="{{ post.get_absolute_url }}">{{ post.title }}</a>
{% endfor %}

We've added an a tag with an href pointing to post.get_absolute_url. To put this all together, our index view grabs all the BlogPost objects in the database, and returns them as posts in the context. Our template, index.html, takes those posts, and for each one, calls BlogPost.get_absolute_url, passing in the relevant slug that is stored in the context. That function generates a URL that points us at the detail view, so when we click on the a tag, we get routed to that view.

Try it out now. Our new blog index, localhost:8000/blog/, should now display the titles of our two blog posts as hyperlinks to the relevant detail pages, where the post content is rendered. Simple :)

Final thoughts

If you're publishing a lot of content, you might eventually get some duplicate slugs. I've ensured that this doesn't pop up as a silent issue by defining unique=True in the BlogPost.slug field; nevertheless, you may want to think about creating a composite slug + ID pattern for your URLs if you think that might be an issue.

You could also consider using Django's prepopulated fields; when you create a post in your admin page, you can use this functionality to automatically create a slug based on the title of your post.



0 comments

Leave a comment