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
- A Post model with author, slug, body, and timestamps
- Admin registration to manage posts in the browser
- URL routing wired to generic ListView and DetailView
- A protected CreateView using LoginRequiredMixin
- Templates with inheritance from a shared base.html
- A Comment model, a ModelForm, and a passing test
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 -> alice2 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 -> postsCommon 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 Feature | Powered By |
|---|---|
| Store posts & comments | models.Model + the ORM and migrations |
| Manage content in the browser | admin.site.register / ModelAdmin |
| Map URLs to pages | urls.py + path() |
| List and show posts | ListView and DetailView |
| Restrict who can post | LoginRequiredMixin on CreateView |
| Reuse page layout | Template inheritance via {% extends %} |
| Collect comments | ModelForm built from the Comment model |
| Prove it works | TestCase + 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
- Previous: Checkpoint: Production Django
- Next: Internationalization (i18n & l10n) — Translate your app with gettext, {% trans %}, LocaleMiddleware and .po files
- Quick reference: Django cheat sheet
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.