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.
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.
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.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.
{% 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.
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")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.
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.
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.
Explore a worked solution
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.
Reject a short title
Add a clean_title method to PostForm that strips whitespace and raises forms.ValidationError when fewer than five characters remain.
Explore a worked solution
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.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
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.
manager ready
Keep exploring.
The official references behind this lesson, when you want a second angle or the full details.
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 moduleOne lesson closer.
Reading is a start. Trying it yourself makes the connection.