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.