Django: Blog Website

9 minute read

Published:

This chapter covers building a database-backed Blog Website with the core features of listing posts and viewing individual post detail pages, while preparing the foundation for create, read, update, and delete operations in the next chapter.

In this chapter, we will learn:

  • Setting up the project6 project with a virtual environment.
  • Creating the Post model with title, author, and body fields.
  • Understanding primary key and foreign key concepts in Django ORM.
  • Managing data with Django Admin.
  • Building function-based views, URL routing, and template inheritance.
  • Adding static files (CSS) to improve the blog appearance.
  • Creating a post detail page and applying get_absolute_url().
  • Writing tests for models, URLs, templates, and content.
  • Using a Git workflow for the initial project commit.

1. Blog Website Overview

In this chapter, we start building a more realistic blog application. If the previous chapter focused on displaying simple data, now we move to a pattern commonly used in almost every CRUD website.

Main focus in this section:

  1. Designing post data structure correctly.
  2. Connecting posts to users through database relations.
  3. Displaying post list and post detail pages in the web interface.
  4. Adding basic visual styling with static files.
  5. Preparing tests to keep changes safe.

Before we start the setup steps, here is the final directory structure we want to achieve for the project6 project:

project6/
├── .venv/
├── db.sqlite3
├── manage.py
├── django_project/
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── blog/
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations/
│   ├── models.py
│   ├── tests.py
│   ├── urls.py
│   └── views.py
├── static/
│   └── css/
│       └── base.css
└── templates/
  ├── base.html
  ├── home.html
  └── post_detail.html

2. Initial Setup for project6

The setup steps are similar to the previous chapter: create the project folder, activate a virtual environment, install dependencies, then create the project and app.

# Windows
cd onedrive\desktop\pawf\django
mkdir project6
cd project6
python -m venv .venv
.venv\Scripts\Activate.ps1

# macOS
cd ~/desktop/pawf/django
mkdir -p project6
cd project6
python3 -m venv .venv
source .venv/bin/activate

Install packages, create project, create app, and run initial migration:

python -m pip install django~=6.0.4
python -m pip install black
django-admin startproject django_project .
python manage.py startapp blog
python manage.py migrate
python manage.py runserver

Add the blog app to INSTALLED_APPS in django_project/settings.py:

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "blog",  # new
]

3. Blog Post Model

We want a Post table with three main fields:

  • title
  • author
  • body

In Django, a model is a Python class representation of a database table. Each class attribute maps to a database column.

Initially, we can define the model as follows in blog/models.py:

from django.db import models


class Post(models.Model):
    title = models.CharField(max_length=200)
    author = models.CharField(max_length=200)
    body = models.TextField()

    def __str__(self):
        return self.title

Then create and apply migrations:

python manage.py makemigrations blog
python manage.py migrate

Important notes:

  • CharField is suitable for short text with a length limit.
  • TextField is suitable for long content.
  • The __str__() method makes object representation more readable in admin and shell.

django output1

4. Primary Key and Foreign Key

Django automatically adds an auto-increment primary key to each model, usually named id.

A primary key serves as the unique identity for each data row. A foreign key links one table to another so data relationships remain consistent.

Because we want each post to be connected to a valid user, the author field should be changed into a foreign key to Django’s built-in user model in blog/models.py:

from django.db import models


class Post(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(
        "auth.User",
        on_delete=models.CASCADE,
    )
    body = models.TextField()

    def __str__(self):
        return self.title

After changing the model schema, run migrations again:

python manage.py makemigrations blog
python manage.py migrate

Meaning of this relation:

  • One user can own many posts (many-to-one from post to user).
  • If a user is deleted, related posts are deleted as well because of on_delete=models.CASCADE.

django output2

5. Django Admin

Create a superuser account:

python manage.py createsuperuser

Register the model in blog/admin.py:

from django.contrib import admin

from .models import Post


admin.site.register(Post)

To show multiple columns in the admin list display, use ModelAdmin and update blog/admin.py:

from django.contrib import admin

from .models import Post


class PostAdmin(admin.ModelAdmin):
    list_display = (
        "title",
        "author",
        "body",
    )


admin.site.register(Post, PostAdmin)

After that, open http://127.0.0.1:8000/admin/, sign in, and add at least two sample posts.

Admin Homepage django admin

Admin Create Post django create post

Admin Post List django postlist

6. Views, URLs, and Templates

These three components are the main Django request-response flow: URL routes requests, view processes logic, and template renders HTML output to users.

6.1 Function-Based View

In blog/views.py, create a view for listing posts:

from django.shortcuts import render

from .models import Post


def post_list(request):
    posts = Post.objects.all()
    return render(request, "home.html", {"posts": posts})

6.2 URL Routing

Create blog/urls.py:

from django.urls import path

from .views import post_list

urlpatterns = [
    path("", post_list, name="home"),
]

Update django_project/urls.py:

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

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("blog.urls")),
]

6.3 Template Inheritance

Create a template directory:

mkdir templates

Update django_project/settings.py:

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": True,
        ...
    },
]

Create templates/base.html:

<html>
<head>
  <title>Django blog</title>
</head>
<body>
  <header>
    <h1><a href="{% url 'home' %}">Django blog</a></h1>
  </header>
  <div>
    {% block content %}
    {% endblock content %}
  </div>
</body>
</html>

Create templates/home.html:

{% extends "base.html" %}

{% block content %}
{% for post in posts %}
<div class="post-entry">
  <h2><a href="">{{ post.title }}</a></h2>
  <p>Author: {{ post.author }}</p>
  <p>{{ post.body }}</p>
</div>
{% endfor %}
{% endblock content %}

django postlist

7. Static Files and Styling

Static files are frontend assets such as CSS, JavaScript, and images that are not rendered as dynamic data. In this chapter, we use CSS to improve blog readability.

Add project-level static folders:

mkdir static
mkdir static/css

Configure in django_project/settings.py:

STATIC_URL = "/static/"  # update
STATICFILES_DIRS = [BASE_DIR / "static"]  # add

Create static/css/base.css:

body {
  font-family: "Source Sans Pro", sans-serif;
  font-size: 18px;
}

header {
  border-bottom: 1px solid #999;
  margin-bottom: 2rem;
  display: flex;
}

header h1 a {
  color: red;
  text-decoration: none;
}

.post-entry {
  margin-bottom: 2rem;
}

.post-entry h2 {
  margin: 0.5rem 0;
}

.post-entry h2 a,
.post-entry h2 a:visited {
  color: blue;
  text-decoration: none;
}

.post-entry h2 a:hover {
  color: red;
}

.post-entry p {
  margin: 0;
  font-weight: 400;
}

Then load static files in templates/base.html:

{% load static %}
<html>
<head>
  <title>Django blog</title>
  <link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400" rel="stylesheet">
  <link href="{% static 'css/base.css' %}" rel="stylesheet">
</head>
...

django postlist fe

8. Detail Page and get_absolute_url()

8.1 Detail view and URL

Update blog/views.py:

from django.shortcuts import get_object_or_404, render # update

from .models import Post


def post_list(request):
    posts = Post.objects.all()
    return render(request, "home.html", {"posts": posts})

# add
def post_detail(request, pk):
    post = get_object_or_404(Post, pk=pk)
    return render(request, "post_detail.html", {"post": post})

Update blog/urls.py:

from django.urls import path

from .views import post_detail, post_list

urlpatterns = [
    path("post/<int:pk>/", post_detail, name="post_detail"),
    path("", post_list, name="home"),
]

Create templates/post_detail.html:

{% extends "base.html" %}

{% block content %}
<div class="post-entry">
  <h2>{{ post.title }}</h2>
  <p>{{ post.body }}</p>
</div>
{% endblock content %}

In templates/home.html, replace the post title link:

<h2><a href="{% url 'post_detail' post.pk %}">{{ post.title }}</a></h2>

django postdetail

8.3 get_absolute_url() best practice

To avoid repeating canonical URL patterns across templates, add the following method in blog/models.py:

get_absolute_url() helps each model object know its own detail URL. This keeps templates cleaner and reduces hardcoded paths.

from django.db import models
from django.urls import reverse


class Post(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey("auth.User", on_delete=models.CASCADE)
    body = models.TextField()

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("post_detail", kwargs={"pk": self.pk})

Then use it in template:

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

9. Testing

Here is a test example that verifies model, URL, template, and content for both list and detail views.

Testing is done with django.test.TestCase, which automatically creates a temporary database so test data is isolated from the development database.

blog/tests.py:

from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse

from .models import Post


class BlogTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = get_user_model().objects.create_user(
            username="testuser", email="test@email.com", password="secret"
        )
        cls.post = Post.objects.create(
            title="A good title",
            body="Nice body content",
            author=cls.user,
        )

    def test_post_model(self):
        self.assertEqual(self.post.title, "A good title")
        self.assertEqual(self.post.body, "Nice body content")
        self.assertEqual(self.post.author.username, "testuser")
        self.assertEqual(str(self.post), "A good title")
        self.assertEqual(self.post.get_absolute_url(), "/post/1/")

    def test_url_exists_at_correct_location_listview(self):
        response = self.client.get("/")
        self.assertEqual(response.status_code, 200)

    def test_url_exists_at_correct_location_detailview(self):
        response = self.client.get("/post/1/")
        self.assertEqual(response.status_code, 200)

    def test_post_listview(self):
        response = self.client.get(reverse("home"))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "Nice body content")
        self.assertTemplateUsed(response, "home.html")

    def test_post_detailview(self):
        response = self.client.get(reverse("post_detail", kwargs={"pk": self.post.pk}))
        no_response = self.client.get("/post/100000/")
        self.assertEqual(response.status_code, 200)
        self.assertEqual(no_response.status_code, 404)
        self.assertContains(response, "A good title")
        self.assertTemplateUsed(response, "post_detail.html")

Run tests:

python manage.py test

Important tip when testing detail URLs: do not forget leading and trailing slashes (/post/1/).

10. Git and Initial Commit

Initialize Git and check changes:

git init
git status

Create a .gitignore file so local environment folders, cache, and local database are not committed:

.venv/
__pycache__/
db.sqlite3

Then commit your work:

git add .
git commit -am "Add project6: blog website with model, admin, views, urls, templates, static files, detail page, get_absolute_url, and tests"

Connect to GitHub and push:

git push -u origin main

11. Conclusion

In this chapter, we successfully built a Blog Website foundation from scratch:

  • Post model with user relationship.
  • Data management through Django Admin.
  • Post list and detail pages with dynamic URL routing.
  • Basic styling with static files.
  • URL best practice with get_absolute_url().
  • A test suite covering model, URL, template, and content.

The next chapter will continue with generic class-based views and forms so create, update, and delete operations can be handled directly in the application interface, not only through admin.