Python for Humans · Draft v0.1 · Oct 29, 2026 · Draft position: chapter 20 · First short project draft.
Your Reading List
- You should already know
- The Django introduction: project/app setup, URL patterns, function views and templates.
- You'll build
- A local reading-list website with an add-book form and a finished checkbox.
- You'll be able to
- Define a model, validate a form, save records and handle a POST request.
- You need
- Your local setup, any plain-text editor, uv and internet access for installing packages. Python 3.14 is selected for these examples.
The first Django page read a file. This time the browser will send information back: a book title and, later, whether you've finished it. Close and reopen the server; the list should still be there.
This project builds on the Django introduction. Keep it separate from that project:
mkdir reading-list cd reading-list uv init --bare --vcs none --no-workspace --python 3.14 uv python pin 3.14 uv add "django>=6.0,<6.1"
uv run django-admin startproject config . uv run python manage.py startapp books
Add 'books' to INSTALLED_APPS in config/settings.py, keeping the existing entries. We'll create the files below before making migrations. If another local server uses port 8000, stop it or choose a different port.
Describe One Book
Replace books/models.py:
# books/models.py from django.db import models class Book(models.Model): title = models.CharField(max_length=200) finished = models.BooleanField(default=False)
A model describes stored records. Book inherits Django's database behavior from models.Model. The field declarations tell Django how to store a title and a Boolean. Each saved book also receives a primary key, available as pk, identifying that record.
Create books/forms.py:
# books/forms.py from django import forms class BookForm(forms.Form): title = forms.CharField(max_length=200)
This form requires a nonblank title of at most 200 characters. Validation happens on the server; a browser's input controls alone aren't enough.
Receive and Save a Form
Replace books/views.py:
# books/views.py from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_POST from .forms import BookForm from .models import Book def index(request): if request.method == 'POST': form = BookForm(request.POST) if form.is_valid(): Book.objects.create(title=form.cleaned_data['title']) return redirect('index') else: form = BookForm() return render(request, 'books/index.html', { 'form': form, 'books': Book.objects.order_by('-id'), }) @require_POST def finish(request, pk): book = get_object_or_404(Book, pk=pk) book.finished = 'finished' in request.POST book.save() return redirect('index')
A GET displays the list; a POST submits data. form.is_valid() checks the submitted title, and cleaned_data supplies its validated value. Invalid submissions reach the template with their error messages.
Book.objects supplies Django's ORM, its tools for querying and changing database records through Python. create() saves a new book. order_by('-id') lists the newest records first. save() writes an existing book's changed state.
redirect() sends the browser back to the list after a successful POST. Refreshing that resulting page repeats a GET rather than adding the book again.
The @require_POST line is a decorator: it applies Django's POST-only behavior to finish. A checked HTML checkbox sends its name; an unchecked one doesn't. That gives our Boolean its value. An unknown book ID produces a 404.
Connect the Pages
Create books/urls.py:
# books/urls.py from django.urls import path from . import views urlpatterns = [ path('', views.index, name='index'), path('finish/<int:pk>/', views.finish, name='finish'), ]
<int:pk> extracts an integer from the URL and passes it to finish as pk. Replace config/urls.py:
# config/urls.py from django.urls import include, path urlpatterns = [path('', include('books.urls'))]
Create books/templates/books/index.html:
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Your reading list</title> </head> <body> <h1>Your reading list</h1> <form method="post"> {% csrf_token %} {{ form.as_p }} <button>Add book</button> </form> <ul> {% for book in books %} <li> {{ book.title }} <form method="post" action="{% url 'finish' book.pk %}"> {% csrf_token %} <label> <input type="checkbox" name="finished" {% if book.finished %}checked{% endif %}> Finished </label> <button>Save status</button> </form> </li> {% empty %} <li>No books yet. Add your first one.</li> {% endfor %} </ul> </body> </html>
form.as_p renders the input, label and any errors. Each POST form includes {% csrf_token %}, which Django uses to check that a form submission has the expected token. Keep it in both forms.
{% url 'finish' book.pk %} builds the update URL for that particular book. Template values remain escaped, just as in the first Django chapter.
Prepare the Database and Try It
uv run python manage.py makemigrations books uv run python manage.py migrate uv run python manage.py runserver
The first command creates migration instructions from the model. The second applies them to the local SQLite database. Visit the reading list.
Add two books. Mark one finished and click its Save status button. Refresh, then stop and restart the server. The list and checkbox should persist because they are stored in db.sqlite3, rather than a view instance.
Submit a blank title. It should display an error and create no record. Add a valid title, refresh and check that a single submission created only one book.
Check the Database Behavior
Replace books/tests.py with:
# books/tests.py from django.test import TestCase from .models import Book class ReadingListTests(TestCase): def test_add_book(self): response = self.client.post('/', {'title': 'A Wizard of Earthsea'}) assert response.status_code == 302 assert Book.objects.get().title == 'A Wizard of Earthsea' def test_blank_title(self): self.client.post('/', {'title': ' '}) assert Book.objects.count() == 0 def test_finish(self): book = Book.objects.create(title='A Wizard of Earthsea') response = self.client.post(f'/finish/{book.pk}/', {'finished': 'on'}) assert response.status_code == 302 book.refresh_from_db() assert book.finished is True
Stop the server and run uv run python manage.py test books. This uses Django's built-in test runner with ordinary assertions. Inheriting TestCase supplies a client and isolates database changes in a test database; your reading list isn't used as the test input. refresh_from_db() reloads the changed record before we check it.
Your Turn
Add an author field. Decide whether it can be blank, update the model, form, view and template, then make and apply a migration. Add a test proving that the submitted author is saved.
An extension is a search box that filters saved titles. An account system and publishing this site require further work; this version is a local, single-user reading list.
What I've Learned
Trace a submitted title from the form to a database record, and explain why the data survives a new request or a restarted server.