LESSON 78 · ADVANCED4 min read

Project: A Django Blog Application

Build a Django blog with a Post model, migrations, public pages, an authenticated create form, and tests that keep drafts private.

Python 3.12+Free to readModule 17 of 19
YOUR NEXT SMALL WINS

By the end, you’ll be able to…

  • Model posts and publishing state with Django's ORM.
  • Connect URLs, function views, and templates with a clear request flow.
  • Validate a ModelForm and follow post/redirect/get.
  • Test draft visibility, form errors, and the create workflow.

01Model the publishing domain

A blog post needs a title, a unique slug, a body, an author, and a publishing state. Draft and published are mutually exclusive states, so a TextChoices field plus a nullable published_at is clearer than guessing from a boolean and a timestamp. The ordering belongs in Meta so every list view agrees.

A migration is part of the source code. Run makemigrations, read the generated file, and commit it with the model change. Add an index for the query the public list actually runs: published posts ordered by publication time. The ORM will happily generate a slow query if the model gives it no help.

blog/models.pyPython
from django.conf import settingsfrom django.db import modelsfrom django.urls import reversefrom django.utils import timezoneclass Post(models.Model):    class Status(models.TextChoices):        DRAFT = "draft", "Draft"        PUBLISHED = "published", "Published"    title = models.CharField(max_length=200)    slug = models.SlugField(max_length=200, unique=True)    body = models.TextField()    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)    status = models.CharField(max_length=10, choices=Status.choices, default=Status.DRAFT)    created_at = models.DateTimeField(auto_now_add=True)    published_at = models.DateTimeField(null=True, blank=True)    class Meta:        ordering = ["-published_at", "-created_at"]        indexes = [models.Index(fields=["status", "-published_at"])]    def __str__(self) -> str:        return self.title    def get_absolute_url(self) -> str:        return reverse("blog:post_detail", args=[self.slug])    def publish(self) -> None:        self.status = self.Status.PUBLISHED        self.published_at = timezone.now()        self.save(update_fields=["status", "published_at"])

02Connect URLs and views

The URL configuration is a public contract. Put the fixed new/ route before the slug route, otherwise the detail view will treat 'new' as a slug. Namespace the app so reverse('blog:post_detail') stays unambiguous when the project grows.

A list view filters to published posts and lets Meta ordering do the sorting. A detail view uses get_object_or_404 with both the slug and the published status, so a draft is indistinguishable from a missing post. The create view requires login, validates the form, assigns the author from request.user, and redirects after success.

blog/urls.py and blog/views.pyPython
# blog/urls.pyfrom django.urls import pathfrom . import viewsapp_name = "blog"urlpatterns = [    path("", views.post_list, name="post_list"),    path("new/", views.post_create, name="post_create"),    path("<slug:slug>/", views.post_detail, name="post_detail"),]# blog/views.pyfrom django.contrib.auth.decorators import login_requiredfrom django.shortcuts import get_object_or_404, redirect, renderfrom .forms import PostFormfrom .models import Postdef post_list(request):    posts = Post.objects.filter(status=Post.Status.PUBLISHED)    return render(request, "blog/post_list.html", {"posts": posts})def post_detail(request, slug):    post = get_object_or_404(Post, slug=slug, status=Post.Status.PUBLISHED)    return render(request, "blog/post_detail.html", {"post": post})@login_requireddef post_create(request):    form = PostForm(request.POST or None)    if request.method == "POST" and form.is_valid():        post = form.save(commit=False)        post.author = request.user        post.save()        return redirect(post)    return render(request, "blog/post_form.html", {"form": form})

03Render with inheritance and validate with a form

A base template owns the document shell and named blocks. Child templates override only the content they change. Django escapes variables by default, so ordinary post text cannot inject markup. Reserve the safe filter for content you have sanitized deliberately, and keep template logic presentational.

A ModelForm derives fields from the model but still needs an explicit fields list; __all__ can expose fields the form should never accept. Bind request.POST on a POST request, call is_valid(), and use cleaned_data only after validation succeeds. Include {% csrf_token %} in every state-changing form and redirect after a successful save.

blog/templates/blog/post_form.htmlHTML
{% extends "blog/base.html" %}{% block title %}New post{% endblock %}{% block content %}  <h1>New post</h1>  <form method="post">    {% csrf_token %}    {{ form.as_p }}    <button type="submit">Save draft</button>  </form>{% endblock %}

04Test the boundary, not the admin

Django's test client exercises the URL resolver, middleware, view, form, and template without starting a server. Test that a draft returns 404, a published post renders its title, a short title produces a form error, and an anonymous create request redirects to login. These are the behaviors a reader depends on.

Create users through get_user_model().objects.create_user so the author relation is real. Keep each test focused on one reason to fail, and use reverse instead of hard-coded paths. Before shipping, run makemigrations --check, migrate from an empty database, and confirm that DEBUG is false and ALLOWED_HOSTS is explicit in production.

blog/tests.pyPython
from django.contrib.auth import get_user_modelfrom django.test import TestCasefrom django.urls import reversefrom .models import Postclass PublicPostTests(TestCase):    def setUp(self):        self.author = get_user_model().objects.create_user("writer", password="secret")    def test_draft_returns_404(self):        post = Post.objects.create(            title="Draft", slug="draft", body="Not ready",            author=self.author, status=Post.Status.DRAFT,        )        response = self.client.get(reverse("blog:post_detail", args=[post.slug]))        self.assertEqual(response.status_code, 404)    def test_published_post_is_visible(self):        post = Post.objects.create(            title="Hello", slug="hello", body="Ready",            author=self.author, status=Post.Status.PUBLISHED,        )        response = self.client.get(reverse("blog:post_detail", args=[post.slug]))        self.assertContains(response, "Hello")
LET IT CLICK

The ideas worth keeping.

  • Models and migrations define the publishing state and the query indexes.
  • Views orchestrate; forms validate; templates render inherited blocks.
  • Tests prove draft privacy, form errors, and the create redirect.
TURN UNDERSTANDING INTO SOMETHING YOU CAN DO

Make it yours.

Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.

01

Add a published manager

Create a PostQuerySet with a published() method and a PostManager that exposes it. Show a list view that uses Post.objects.published() instead of repeating the filter.

your_turn_1.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
from django.db import modelsclass PostQuerySet(models.QuerySet):    def published(self):        return self.filter(status="published")class PostManager(models.Manager.from_queryset(PostQuerySet)):    pass# In the model:# objects = PostManager()## In the view:# posts = Post.objects.published()print("manager ready")

A queryset method names a reusable query and keeps the filter in one place. The manager makes it available from Post.objects without a custom model method.

02

Reject a short title

Add a clean_title method to PostForm that strips whitespace and raises forms.ValidationError when fewer than five characters remain.

your_turn_2.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
from django import formsclass PostForm(forms.ModelForm):    def clean_title(self):        title = self.cleaned_data["title"].strip()        if len(title) < 5:            raise forms.ValidationError("Use at least five characters.")        return titleprint("form validation ready")

clean_<field> runs after the field's built-in validation and returns the value stored in cleaned_data. Keep authorization checks out of the form; they belong in the view or a policy layer.

A SMALL QUESTION. A USEFUL NEXT STEP.

What stayed with you?

Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.

A LITTLE ACTIVE RECALL0 / 3 answered
01Why put new/ before the slug route?
02What does get_object_or_404 hide from a visitor?
03What should happen after a valid create POST?
⚡CODING CHALLENGE
🔒Required to Mark Lesson Complete

Add a published manager

Create a PostQuerySet with a published() method and a PostManager that exposes it. Show a list view that uses Post.objects.published() instead of repeating the filter.

EXPECTED OUTPUT
manager ready
main.pyPython 3
Ctrl + ↵
Execution Console
Click "Run & Verify" (or press Ctrl + Enter) to test your code against the challenge.

Keep exploring.

The official references behind this lesson, when you want a second angle or the full details.

MODULE 17 · PRACTICAL SELF-CHECK

Pause. Put the pieces together.

You’ve reached the end of Web Development with Flask and Django. Before moving on, use this task to check what you can do independently.

Ship a tested Flask endpoint, then a Django app with a model, CRUD views, template inheritance, form validation, and draft privacy tests.

Try it in your own environment and write down the result. This is a self-check; reading completion does not evaluate your code.

Review this module

One lesson closer.

Reading is a start. Trying it yourself makes the connection.

Solve the hands-on coding challenge in the knowledge section to verify your code and unlock lesson completion.