Capstone: A Blog App

Reviewed & published by Brayan K

This capstone brings together everything you've learned by building a complete Django blog application — models for posts and comments, the admin, URL routing, class-based and generic views, templates with inheritance, forms, authentication, and tests — assembled into one working project from start to finish.

Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

Instead of introducing a brand-new idea, this final lesson is a guided build. We'll wire each piece together in the order you'd build a real site, so by the end you'll have a blog you understand top to bottom and a template you can reuse for your own projects.

What You'll Build in This Capstone

1 The Post Model and the Admin

Every blog starts with its data. The Post model is a Python class that maps to a database table. It links each post to its author with a ForeignKey, stores a URL-friendly slug, the body text, and created / published timestamps. The __str__ method gives each post a readable name, and get_absolute_url tells Django where a single post lives.

# blog/models.py
from django.db import models
from django.contrib.auth.models import User
from django.urls import reverse


class Post(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    author = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name="blog_posts",
    )
    body = models.TextField()
    created = models.DateTimeField(auto_now_add=True)
    published = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-published"]

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("blog:post_detail", args=[self.slug])

Once the model exists, you run python manage.py makemigrations and migrate to create the table, then register the model so it appears in Django's admin:

# blog/admin.py
from django.contrib import admin
from .models import Post


@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ["title", "author", "published"]
    prepopulated_fields = {"slug": ("title",)}
    search_fields = ["title", "body"]

Here is the shape of that model in plain Python, so you can see how a post object behaves before a database is involved.

# A simplified, runnable stand-in for the Post model so you can see
# how __str__ and get_absolute_url behave. Real Django adds the
# database layer on top of exactly this idea.

class Post:
    def __init__(self, title, slug, author, body):
        self.title = title
        self.slug = slug
        self.author = author
        self.body = body

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        # In Django this calls reverse("blog:post_detail", args=[self.slug])
        return f"/blog/{self.slug}/"


post = Post(
    title="Hello, Django!",
    slug="hello-django",
    author="alice",
    body="My first post.",
)

print("str(post)      ->", str(post))
print("absolute url   ->", post.get_absolute_url())
print("written by     ->", post.author)

# ✅ Expected output:
# str(post)      -> Hello, Django!
# absolute url   -> /blog/hello-django/
# written by     -> alice

2 URLs, Generic Views, and Template Inheritance

Now we expose the posts to the web. Django's generic views handle the common cases for us: ListView for the post list, DetailView for one post, and CreateView for a new post. We protect CreateView with LoginRequiredMixin so only signed-in users can post.

# blog/views.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView, DetailView, CreateView
from .models import Post


class PostListView(ListView):
    model = Post
    template_name = "blog/post_list.html"
    context_object_name = "posts"


class PostDetailView(DetailView):
    model = Post
    template_name = "blog/post_detail.html"


class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    fields = ["title", "slug", "body"]
    template_name = "blog/post_form.html"

    def form_valid(self, form):
        form.instance.author = self.request.user
        return super().form_valid(form)

Each view is connected to a URL in the app's urls.py. The names (post_list, post_detail) are what get_absolute_url and the templates refer to.

# blog/urls.py
from django.urls import path
from . import views

app_name = "blog"

urlpatterns = [
    path("", views.PostListView.as_view(), name="post_list"),
    path("new/", views.PostCreateView.as_view(), name="post_create"),
    path("<slug:slug>/", views.PostDetailView.as_view(), name="post_detail"),
]

Templates use inheritance. A shared base.html defines the page shell with a {% block content %} hole; each page fills that hole with {% extends "blog/base.html" %} and its own {% block content %}.

<!-- blog/templates/blog/base.html -->
<!DOCTYPE html>
<html>
<head><title>{% block title %}My Blog{% endblock %}</title></head>
<body>
  <header><a href="{% url 'blog:post_list' %}">My Blog</a></header>
  <main>
    {% block content %}{% endblock %}
  </main>
</body>
</html>
<!-- blog/templates/blog/post_list.html -->
{% extends "blog/base.html" %}

{% block title %}All Posts{% endblock %}

{% block content %}
  <h1>Latest Posts</h1>
  {% for post in posts %}
    <article>
      <h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
      <p>by {{ post.author }} on {{ post.published|date:"M d, Y" }}</p>
    </article>
  {% endfor %}
{% endblock %}
<!-- blog/templates/blog/post_detail.html -->
{% extends "blog/base.html" %}

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

{% block content %}
  <h1>{{ post.title }}</h1>
  <p>by {{ post.author }} on {{ post.published|date:"M d, Y" }}</p>
  <div>{{ post.body|linebreaks }}</div>
{% endblock %}
# Generic ListView essentially fetches objects and renders a template.
# Here's the core idea in plain Python: gather posts, then "render"
# them the way the post_list template loops with {% for post in posts %}.

posts = [
    {"title": "Hello, Django!", "author": "alice"},
    {"title": "Models and Migrations", "author": "bob"},
    {"title": "Generic Views Are Great", "author": "alice"},
]

def render_post_list(posts):
    lines = ["Latest Posts", "-" * 20]
    for post in posts:           # like {% for post in posts %}
        lines.append(f"{post['title']} (by {post['author']})")
    return "\n".join(lines)

print(render_post_list(posts))

# ✅ Expected output:
# Latest Posts
# --------------------
# Hello, Django! (by alice)
# Models and Migrations (by bob)
# Generic Views Are Great (by alice)

3 Comments, a ModelForm, and a Test

A blog needs conversation. The Comment model has a ForeignKey to Post, so one post can have many comments. The related_name="comments" lets a template read post.comments.all.

# blog/models.py (continued)
class Comment(models.Model):
    post = models.ForeignKey(
        Post,
        on_delete=models.CASCADE,
        related_name="comments",
    )
    name = models.CharField(max_length=80)
    body = models.TextField()
    created = models.DateTimeField(auto_now_add=True)
    approved = models.BooleanField(default=False)

    class Meta:
        ordering = ["created"]

    def __str__(self):
        return f"Comment by {self.name} on {self.post}"

A ModelForm turns that model into an HTML form automatically — you just list the fields you want users to fill in.

# blog/forms.py
from django import forms
from .models import Comment


class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ["name", "body"]

Finally, a test proves the list view works. Django's TestCase uses a temporary database and a test client to request the page and check the response. The example below is plain unittest so it runs right here in the editor; the comment shows the equivalent Django test.

# A real Django test looks like this:
#
#   from django.test import TestCase
#   from django.urls import reverse
#   from .models import Post
#
#   class PostListViewTest(TestCase):
#       def test_list_view_returns_200_and_post(self):
#           Post.objects.create(title="Hello", slug="hello", body="hi")
#           response = self.client.get(reverse("blog:post_list"))
#           self.assertEqual(response.status_code, 200)
#           self.assertContains(response, "Hello")
#
# Below is a runnable plain-unittest version that simulates the view and
# asserts exactly the same two things: status 200 and the post is present.

import unittest


def post_list_view(posts):
    """Stand-in for Django's ListView returning an HTTP response."""
    html = "<h1>Latest Posts</h1>"
    for post in posts:
        html += f"<h2>{post['title']}</h2>"
    return {"status_code": 200, "content": html}


class PostListViewTest(unittest.TestCase):
    def test_list_view_returns_200_and_contains_post(self):
        posts = [{"title": "Hello"}]
        response = post_list_view(posts)
        self.assertEqual(response["status_code"], 200)
        self.assertIn("Hello", response["content"])


unittest.main(argv=["", "-v"], exit=False)

# EXPECTED: the test passes (OK).

🎯 Your Turn: Finish the ListView

A ListView needs to know which model to list. Replace ___ so the view lists Post objects, then print the configured attributes.

# 🎯 YOUR TURN: replace ___ with the model name "Post"
# so this stand-in ListView is configured correctly.

class PostListView:
    model = "___"
    template_name = "blog/post_list.html"
    context_object_name = "posts"


view = PostListView()
print("model         ->", view.model)
print("template_name ->", view.template_name)
print("context name  ->", view.context_object_name)

# EXPECTED OUTPUT:
# model         -> Post
# template_name -> blog/post_list.html
# context name  -> posts

Common Errors (And How to Fix Them)

❌ TypeError: __init__() missing 1 required positional argument: 'on_delete'

You declared a ForeignKey without telling Django what to do when the parent row is deleted.

✅ Fix: add the argument, e.g. models.ForeignKey(Post, on_delete=models.CASCADE).

❌ Anyone can reach the "new post" page without logging in

You forgot to protect the create view, so unauthenticated users can post.

✅ Fix: add LoginRequiredMixin as the first base class: class PostCreateView(LoginRequiredMixin, CreateView).

Django can't find the template because the path or folder nesting is wrong.

✅ Fix: put templates in blog/templates/blog/ and reference them as "blog/post_list.html".

📋 Quick Reference: Blog Feature → Tool

Blog FeaturePowered By
Store posts & commentsmodels.Model + the ORM and migrations
Manage content in the browseradmin.site.register / ModelAdmin
Map URLs to pagesurls.py + path()
List and show postsListView and DetailView
Restrict who can postLoginRequiredMixin on CreateView
Reuse page layoutTemplate inheritance via {% extends %}
Collect commentsModelForm built from the Comment model
Prove it worksTestCase + the test client

🏆 Mini Challenge: Tags and a Published Filter

Extend the blog two ways. In Django you'd add a tags = models.ManyToManyField(Tag) to Post and override get_queryset on the list view to show only published posts. Practice the filtering logic below in plain Python.

# 🏆 Mini Challenge: only published posts, and group them by tag.
# In Django this is Post.objects.filter(status="published") plus a
# ManyToManyField for tags. Here's the same logic in plain Python.

posts = [
    {"title": "Hello, Django!", "status": "published", "tags": ["intro"]},
    {"title": "Secret Draft",    "status": "draft",     "tags": ["intro"]},
    {"title": "Generic Views",   "status": "published", "tags": ["views", "intro"]},
]

# 1. Filter to published posts only (like get_queryset).
published = [p for p in posts if p["status"] == "published"]
print("Published posts:")
for p in published:
    print(" -", p["title"])

# 2. Build a tag -> titles index (like a ManyToManyField).
by_tag = {}
for p in published:
    for tag in p["tags"]:
        by_tag.setdefault(tag, []).append(p["title"])

print("\nPosts by tag:")
for tag, titles in sorted(by_tag.items()):
    print(f"  {tag}: {', '.join(titles)}")

# EXPECTED: "Secret Draft" never appears; tags map to published titles.

❓ Frequently Asked Questions

🎉 You finished the entire Django course!

This is the final lesson — and you didn't just read about Django, you built a real blog with it. You created models for posts and comments, registered them in the admin, routed URLs to generic class-based views, protected actions with authentication, shared layout through template inheritance, accepted input with a ModelForm, and proved the whole thing works with a test.

That means you now understand the full Django stack — the ORM, the admin, URLs, views, templates, forms, auth, and testing — and how the pieces fit together into one working project. Every database-driven site you build from here is a variation on what you just made.

🏆 Congratulations — keep this blog as your starter project, extend it with your own ideas, and go build something real. Well done!

Practice quiz

Which generic view shows a single object?

  • ListView
  • DetailView
  • CreateView
  • TemplateView

Answer: DetailView. DetailView renders one object; ListView shows a list and CreateView a form.

Which generic view displays a list of objects?

  • ListView
  • DetailView
  • FormView
  • RedirectView

Answer: ListView. ListView is the pre-built CBV for showing a list of model instances.

Which mixin restricts the create view to logged-in users?

  • PermissionRequiredMixin
  • ContextMixin
  • UserPassesTestMixin
  • LoginRequiredMixin

Answer: LoginRequiredMixin. LoginRequiredMixin, listed before the base view, blocks anonymous users.

What relationship does a Comment with a ForeignKey to Post create?

  • One-to-one
  • Many-to-many
  • Many-to-one (one post, many comments)
  • No relationship

Answer: Many-to-one (one post, many comments). A ForeignKey is many-to-one: many comments belong to one post.

What does on_delete=models.CASCADE do when a post is deleted?

  • Deletes the post's comments too
  • Sets comments to null
  • Blocks the deletion
  • Archives the post

Answer: Deletes the post's comments too. CASCADE deletes the related comments when the parent post is deleted.

What lets you read a post's comments as post.comments.all()?

  • on_delete
  • related_name on the ForeignKey
  • context_object_name
  • get_absolute_url

Answer: related_name on the ForeignKey. related_name='comments' names the reverse accessor from Post to its comments.

What does a ModelForm give you?

  • A database migration
  • A URL route
  • An HTML form built automatically from a model
  • A test client

Answer: An HTML form built automatically from a model. A ModelForm derives form fields from the model so you list the fields you want.

Which base class spins up a temporary database and test client?

  • unittest.TestCase only
  • models.Model
  • django.test.TestCase
  • ListView

Answer: django.test.TestCase. Django's TestCase provides a temp database and self.client to exercise views.

What does get_absolute_url typically call to build a URL?

  • reverse()
  • redirect()
  • render()
  • include()

Answer: reverse(). get_absolute_url uses reverse('blog:post_detail', args=[...]) to build the URL.

Where must templates live to be found as 'blog/post_list.html'?

  • The project root
  • blog/templates/blog/
  • static/blog/
  • blog/html/

Answer: blog/templates/blog/. App templates go in app/templates/app/ so they are namespaced and found correctly.

Continue this course

Frequently asked questions

Why build a blog as the capstone project?

A blog is the perfect capstone because it exercises every part of the Django stack in a small, understandable domain. You model related data (posts and comments), expose it through the admin, route URLs, render it with class-based and generic views, present it with inherited templates, accept user input through forms, protect actions with authentication, and prove it works with tests. If you can build a blog confidently, you can build almost any database-driven site.

What is the difference between a generic view and a regular class-based view?

A class-based view (CBV) is any view written as a class. Generic views are pre-built CBVs that Django ships for common tasks: ListView shows a list of objects, DetailView shows a single object, and CreateView renders a form and saves a new object. You set a few attributes like model, template_name, and fields, and Django supplies the request-handling logic. They save you from rewriting the same list-and-detail boilerplate on every project.

How do comments relate to posts in the database?

Each Comment has a ForeignKey to Post, which creates a many-to-one relationship: one post can have many comments, and every comment belongs to exactly one post. The related_name on that ForeignKey lets you read a post's comments with post.comments.all() in templates and views. Setting on_delete=models.CASCADE means deleting a post also deletes its comments, keeping the database tidy.

Do I really need to write tests for a small blog?

Yes — even a small test pays for itself. A single test that loads the post list and asserts the response is 200 and contains a known post catches a huge class of regressions: broken URLs, template errors, view exceptions, and bad querysets. Django's TestCase spins up a temporary database and a test client so you can exercise real views without a browser, and you can run them with python manage.py test.