10 · Project — A Reading-List App¶
Time to put Level 1 together. You'll build a personal reading list: add books, filter by status, mark one finished, rate it, edit and delete. It's small, but it uses every lesson from this level: a project and app, URLs and views, template inheritance, a model with choices and migrations, ORM queries with an aggregate, the admin, a validated ModelForm, CSRF-protected POSTs and a static stylesheet.
Everything below was built and exercised end to end on Django 6.1.1 with the test client; the results are listed at the end so you can compare.
Requirements¶
- A list page showing all books, newest first, with tabs for each status and a count
per status. An unknown
?status=value should show everything, not crash or show nothing. - Add and edit pages sharing one form. Title and author are required. A rating (1–5) is allowed only on finished books.
- A detail page with a Mark finished button that sets the status and today's date. It must only work via POST.
- Delete with a confirmation page; the actual delete must be a POST.
- All entries manageable in the admin, with search and a status filter.
Step 1 — project and app¶
mkdir readinglist && cd readinglist
python3 -m venv .venv && source .venv/bin/activate
python -m pip install django
django-admin startproject config .
python manage.py startapp books
Add "books" to INSTALLED_APPS and set "DIRS": [BASE_DIR / "templates"] in
TEMPLATES.
Step 2 — the model¶
from django.db import models
from django.urls import reverse
from django.utils import timezone
class Entry(models.Model):
class Status(models.TextChoices):
WANT = "want", "Want to read"
READING = "reading", "Reading"
DONE = "done", "Finished"
title = models.CharField(max_length=200)
author = models.CharField(max_length=200)
status = models.CharField(max_length=10, choices=Status, default=Status.WANT)
rating = models.PositiveSmallIntegerField(null=True, blank=True,
help_text="1–5, only for finished books")
notes = models.TextField(blank=True)
added = models.DateTimeField(auto_now_add=True)
finished_on = models.DateField(null=True, blank=True)
class Meta:
ordering = ["-added"]
verbose_name_plural = "entries"
def __str__(self):
return f"{self.title} — {self.author}"
def get_absolute_url(self):
return reverse("books:detail", args=[self.pk])
def mark_finished(self, rating=None):
self.status = self.Status.DONE
self.finished_on = timezone.localdate()
if rating is not None:
self.rating = rating
self.save(update_fields=["status", "finished_on", "rating"])
Design notes:
authoris a plainCharFieldhere. A separateAuthormodel with a foreign key is better once you want "all books by this author"; Level 2 · 01 makes that change.ratingis nullable because "not rated" is genuinely different from any number.mark_finished()lives on the model, not the view. Business rules belong with the data, where the admin, a management command or an API can reuse them.verbose_name_pluralfixes the admin's "Entrys".
Step 3 — the form¶
from django import forms
from .models import Entry
class EntryForm(forms.ModelForm):
class Meta:
model = Entry
fields = ["title", "author", "status", "rating", "notes"]
widgets = {"notes": forms.Textarea(attrs={"rows": 4})}
def clean_rating(self):
rating = self.cleaned_data.get("rating")
if rating is not None and not 1 <= rating <= 5:
raise forms.ValidationError("Rating must be between 1 and 5.")
return rating
def clean(self):
cleaned = super().clean()
if cleaned.get("rating") and cleaned.get("status") != Entry.Status.DONE:
self.add_error("rating", "Only finished books can be rated.")
return cleaned
self.add_error("rating", ...) attaches a cross-field error to a specific field instead
of the form-wide __all__ list, so it appears next to the rating input where the user
will look for it.
Step 4 — views¶
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from .forms import EntryForm
from .models import Entry
def entry_list(request):
entries = Entry.objects.all()
status = request.GET.get("status")
if status in Entry.Status.values:
entries = entries.filter(status=status)
else:
status = None
counts = dict(Entry.objects.values_list("status").annotate(n=Count("id")))
tabs = [(value, label, counts.get(value, 0)) for value, label in Entry.Status.choices]
return render(request, "books/entry_list.html",
{"entries": entries, "status": status, "tabs": tabs})
def entry_detail(request, pk):
entry = get_object_or_404(Entry, pk=pk)
return render(request, "books/entry_detail.html", {"entry": entry})
def entry_form(request, pk=None):
entry = get_object_or_404(Entry, pk=pk) if pk else None
form = EntryForm(request.POST or None, instance=entry)
if request.method == "POST" and form.is_valid():
entry = form.save()
return redirect(entry)
return render(request, "books/entry_form.html", {"form": form, "entry": entry})
@require_POST
def entry_finish(request, pk):
entry = get_object_or_404(Entry, pk=pk)
entry.mark_finished()
return redirect(entry)
def entry_delete(request, pk):
entry = get_object_or_404(Entry, pk=pk)
if request.method == "POST":
entry.delete()
return redirect("books:list")
return render(request, "books/entry_confirm_delete.html", {"entry": entry})
The counts query is worth reading closely. values_list("status").annotate(n=Count("id"))
groups by status and counts each group in one query, producing pairs like
("want", 1); dict() turns them into a lookup. Statuses with no books are missing from
that result, so counts.get(value, 0) fills them in. Validating status against
Entry.Status.values means a hand-edited URL can't produce a confusing empty page.
EntryForm(request.POST or None, instance=entry) is a compact idiom: on GET,
request.POST is an empty (falsy) QueryDict, so the form is unbound. One view handles
both create (pk=None) and edit.
Step 5 — URLs¶
from django.urls import path
from . import views
app_name = "books"
urlpatterns = [
path("", views.entry_list, name="list"),
path("new/", views.entry_form, name="create"),
path("<int:pk>/", views.entry_detail, name="detail"),
path("<int:pk>/edit/", views.entry_form, name="update"),
path("<int:pk>/finish/", views.entry_finish, name="finish"),
path("<int:pk>/delete/", views.entry_delete, name="delete"),
]
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("books.urls")),
]
Step 6 — templates and CSS¶
{% load static %}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Reading list{% endblock %}</title>
<link rel="stylesheet" href="{% static 'books/site.css' %}">
</head>
<body>
<header><a href="{% url 'books:list' %}">📚 Reading list</a>
<a class="button" href="{% url 'books:create' %}">Add a book</a></header>
<main>{% block content %}{% endblock %}</main>
</body>
</html>
{% extends "base.html" %}
{% block content %}
<nav class="tabs">
<a href="{% url 'books:list' %}"{% if not status %} aria-current="page"{% endif %}>All</a>
{% for value, label, n in tabs %}
<a href="?status={{ value }}"{% if status == value %} aria-current="page"{% endif %}>{{ label }} ({{ n }})</a>
{% endfor %}
</nav>
<ul class="entries">
{% for entry in entries %}
<li>
<a href="{{ entry.get_absolute_url }}">{{ entry.title }}</a> — {{ entry.author }}
<span class="status">{{ entry.get_status_display }}</span>
{% if entry.rating %}<span>{{ entry.rating }}/5</span>{% endif %}
</li>
{% empty %}
<li>Nothing here yet. <a href="{% url 'books:create' %}">Add your first book.</a></li>
{% endfor %}
</ul>
{% endblock %}
{% extends "base.html" %}
{% block title %}{{ entry.title }}{% endblock %}
{% block content %}
<h1>{{ entry.title }}</h1>
<p>by {{ entry.author }} · {{ entry.get_status_display }}{% if entry.finished_on %} on {{ entry.finished_on|date:"j M Y" }}{% endif %}</p>
{% if entry.notes %}<p>{{ entry.notes|linebreaksbr }}</p>{% endif %}
{% if entry.status != "done" %}
<form method="post" action="{% url 'books:finish' entry.pk %}">{% csrf_token %}<button>Mark finished</button></form>
{% endif %}
<a href="{% url 'books:update' entry.pk %}">Edit</a> · <a href="{% url 'books:delete' entry.pk %}">Delete</a>
{% endblock %}
entry_form.html is the form template from lesson 08 with a heading that says "Add a
book" or "Edit “…”" depending on whether entry is set. entry_confirm_delete.html is a
one-line question plus a POST form with {% csrf_token %} and a Cancel link.
|linebreaksbr keeps the user's line breaks in notes and escapes the text, so notes
can't inject HTML.
A small stylesheet at books/static/books/site.css (layout, tab styling, red
.errorlist) completes it. Notice aria-current="page" on the active tab: it's both
the styling hook and how screen readers announce the current tab.
Step 7 — admin¶
from django.contrib import admin
from .models import Entry
@admin.register(Entry)
class EntryAdmin(admin.ModelAdmin):
list_display = ["title", "author", "status", "rating", "added"]
list_filter = ["status"]
search_fields = ["title", "author"]
What we observed¶
We drove the finished app with Django's test client, in this order:
| Action | Result |
|---|---|
GET / on an empty database |
200, shows "Add your first book" |
| POST a reading book with rating 4 | 200, form re-shown: "Only finished books can be rated." |
| POST the same book without a rating | 302 → /1/ |
GET /1/finish/ |
405 (the view is POST-only) |
POST /1/finish/ |
302; status became done, finished_on set |
| Edit with rating 9 | "Rating must be between 1 and 5." |
GET / after adding a second, unread book |
tabs read Want to read (1), Reading (0), Finished (1) |
GET /?status=bogus |
200, all entries listed, no tab highlighted |
GET /2/delete/ then POST /2/delete/ |
confirmation page, then 302 → /; one entry left |
GET /999/ |
404 |
The rating check in clean_rating() duplicates what a database CHECK constraint could
enforce. Forms protect the UI; constraints protect the data from every other path (bulk
updates, scripts). Level 3 · 05 adds that constraint.
How It Actually Works¶
Follow one click on Mark finished. The browser POSTs /1/finish/ with the CSRF
token from the hidden field and the csrftoken cookie. Middleware runs top-down:
SessionMiddleware, then CsrfViewMiddleware, which compares token and cookie before
any of your code runs. The resolver walks config.urls, matches the empty prefix of the
include(), then <int:pk>/finish/ in books.urls, converting "1" to 1.
require_POST checks the method (which is how the GET got a 405 without touching the
database), get_object_or_404 runs one SELECT, mark_finished() runs one UPDATE
touching three columns because of update_fields, and redirect(entry) calls
get_absolute_url() to build the Location header. The browser follows it with a GET,
so refreshing the page now just reloads the detail view, never re-finishes the book.
Common mistakes¶
- Making "finish" or "delete" a link (GET). Browser prefetchers and crawlers follow links; a GET that changes data will eventually be triggered by something that isn't a person.
- Using two different templates for create and edit that drift apart.
- Counting each status with a separate
count()query, three queries where one grouped query will do. - Rendering notes with
|safeto keep formatting.
Exercise¶
Build the app, then extend it:
- Add a
?q=search box on the list page that matches title or author (you'll need aQobject: look ahead to Level 2 · 02 or the docs). - Add a "Currently reading" section at the top of the list that only shows when there's at least one book in that status. Use one query, not two.
- Add a
started_ondate that's set automatically when status changes to "reading". Where should that logic live? - Show the average rating of finished books on the list page using
aggregate(Avg(...)).