The Django project from the previous lesson has the foundations — a migrated Book model, a working admin, a greeting view — but visitors still see nothing useful. Today we complete the catalog app until it matches the Flask version: a browsable catalog, a book page with the member price, and a public book-entry form that validates itself. Along the way you'll learn the ORM's query language (QuerySets), Django templates (you'll discover you almost know them already), the POST-redirect-GET pattern, flash messages and what that CSRF thing is that Django protects you from without asking permission. At the end, the table that sums up the entire module: same functionality, two roads — and Papyrus, at last, open to the world.

Contents

  1. QuerySets: asking the database in Python
  2. Book.DoesNotExist and get_object_or_404
  3. Views with templates: render and the context
  4. Named URLs
  5. Django templates: inheritance, {% url %} and filters
  6. The book page: stock and member price
  7. Forms with ModelForm: the book entry
  8. clean_price: custom validation
  9. POST-redirect-GET and flash messages
  10. CSRF: what that token protects you from
  11. Flask vs Django: same functionality, two roads
  12. Deployment: an honest mention and the module's close

QuerySets: asking the database in Python

In Flask, the catalog was an in-memory dictionary and you searched it with your M3 functions. In Django, the data lives in the database and is queried through Book.objects, the model's manager. Its methods return QuerySets — lazy collections that behave like lists — or instances:

Method Returns Example
objects.all() QuerySet with everything Book.objects.all()
objects.filter(...) QuerySet with those that match Book.objects.filter(stock__gt=0)
objects.exclude(...) QuerySet with those that do NOT match Book.objects.exclude(stock=0)
objects.get(...) One instance (or an exception) Book.objects.get(title="Hamlet")
objects.count() Integer Book.objects.count()4
objects.order_by(...) Ordered QuerySet Book.objects.order_by("price")

The field__operator syntax (double underscore) is the condition vocabulary: stock__gt=0 (greater than, more than zero), title__icontains="ody" (contains, case-insensitive — perfect for the search feature), price__lte=15 (less than or equal). Compare mentally with M4: [b for b in catalog.values() if b.stock > 0] and Book.objects.filter(stock__gt=0) express the same thing; the difference is that the QuerySet is resolved by the database, which is optimized for exactly that.

Book.DoesNotExist and get_object_or_404

objects.get() is the Django version of our M7 get_book(), with the same philosophy: if there's no result, it raises an exceptionBook.DoesNotExist. And the exception → 404 translation you built in Flask with errorhandler, Django ships ready-made in a shortcut you'll use constantly:

from django.shortcuts import get_object_or_404

book = get_object_or_404(Book, title="Hamlet")
# = tries objects.get(); if Book.DoesNotExist is raised, responds 404 and done

Recognize the pattern: it's your BookNotFoundError + errorhandler pair from 10-03, packaged into a function. Frameworks converge on the same ideas because the problems are the same.

Views with templates: render and the context

Goodbye to HTML embedded in HttpResponse. The catalog view, in catalog/views.py:

from django.shortcuts import render, get_object_or_404
from .models import Book

def home(request):
    books = Book.objects.order_by("title")
    return render(request, "catalog/home.html", {"books": books})

def detail(request, title):
    book = get_object_or_404(Book, title=title)
    return render(request, "catalog/detail.html", {"book": book})

render(request, template, context) is Flask's render_template with two nuances: the request travels as the first argument (a Django habit) and the context is an explicit dictionary instead of keyword arguments. An app's templates live in catalog/templates/catalog/ — the repeated subfolder avoids name collisions between apps; it feels odd the first time and automatic the second.

Named URLs

We wire up the views in papyrus_web/urls.py, now with the name parameter:

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", views.home, name="home"),
    path("book/<str:title>/", views.detail, name="detail"),
    path("add/", views.add_book, name="add"),   # we write it shortly
]

<str:title> is the route variable — Flask's /book/<title> with the type spelled out in front. The name is the quiet improvement: templates will link by name ({% url 'detail' book.title %}), so if tomorrow you change book/ to page/, every link on the site updates itself. URLs hand-written in templates: technical debt; URLs by name: maintenance for free.

Django templates: inheritance, {% url %} and filters

Good news: Django's template engine is so similar to Jinja2 (10-02) that this section is half review — {{ }} prints, {% %} is logic, {% extends %}/{% block %} inherit. catalog/templates/catalog/base.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>{% block title %}Papyrus{% endblock %}</title>
</head>
<body>
    <header><a href="{% url 'home' %}">Papyrus</a> — your neighborhood bookshop</header>
    <main>{% block content %}{% endblock %}</main>
</body>
</html>

And home.html, with the catalog table:

{% extends "catalog/base.html" %}
{% block title %}Catalog — Papyrus{% endblock %}
{% block content %}
<h1>Catalog ({{ books|length }} titles)</h1>
<table>
    {% for book in books %}
    <tr>
        <td><a href="{% url 'detail' book.title %}">{{ book.title }}</a></td>
        <td>{{ book.price|floatformat:2 }} EUR</td>
        <td>{% if book.in_stock %}{{ book.stock }}{% else %}Out of stock{% endif %}</td>
    </tr>
    {% empty %}
    <tr><td>The catalog is empty.</td></tr>
    {% endfor %}
</table>
<p><a href="{% url 'add' %}">Add a book</a></p>
{% endblock %}

The differences from Jinja2, which are few and specific:

Aspect Jinja2 (Flask) Django templates
Calling methods book.in_stock() with parentheses book.in_stock without parentheses (Django adds them)
Formatting numbers `{{ "%.2f" format(x) }}`
Internal links url_for('view', ...) in Python the {% url 'name' arg %} tag in the template
Empty loop {% else %} inside the for {% empty %}
Allowed logic Almost any Python expression Deliberately limited (logic belongs in the view)

That last row is Django philosophy in miniature: if a template needs to compute, the computation belongs in the view or the model. MTV with discipline.

The book page: stock and member price

catalog/templates/catalog/detail.html — the shop window Julia will see from her phone:

{% extends "catalog/base.html" %}
{% block title %}{{ book.title }} — Papyrus{% endblock %}
{% block content %}
<h1>{{ book.title }}</h1>
<ul>
    <li>Price: {{ book.final_price|floatformat:2 }} EUR</li>
    <li>Member price: {{ book.member_price|floatformat:2 }} EUR</li>
    <li>{% if book.in_stock %}In stock: {{ book.stock }} copies{% else %}Out of stock{% endif %}</li>
</ul>
<a href="{% url 'home' %}">← Back to the catalog</a>
{% endblock %}

A small toll of the "no parentheses" rule: the template can't pass member=True, so we add to the model an argument-free method that delegates to the usual one:

# in catalog/models.py, inside Book
def member_price(self):
    return self.final_price(member=True)

The M5 rule intact: the computation lives in the model, the template only displays it. Hamlet shows 10.35 EUR as the final price and 9.83 EUR for members — the canonical figure the M9 suite has been watching for three modules.

Forms with ModelForm: the book entry

In Flask (10-03) you validated the entry by hand: checking fields, converting types, responding 400 with a message. Django generates all of that from the model. Create catalog/forms.py:

from django import forms
from .models import Book

class BookForm(forms.ModelForm):
    class Meta:
        model = Book
        fields = ["title", "price", "stock"]

That's it: ModelForm reads the model's fields (CharField, DecimalField, IntegerField) and manufactures the HTML form, the type conversion and the validation. The view that uses it:

from django.shortcuts import redirect
from django.contrib import messages
from .forms import BookForm

def add_book(request):
    if request.method == "POST":
        form = BookForm(request.POST)
        if form.is_valid():
            book = form.save()                     # persists: the gift, again
            messages.success(request, f"'{book.title}' added to the catalog.")
            return redirect("detail", title=book.title)
    else:
        form = BookForm()
    return render(request, "catalog/add.html", {"form": form})

And its template, minimal:

{% extends "catalog/base.html" %}
{% block content %}
<h1>Add a book</h1>
<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Save</button>
</form>
{% endblock %}

{{ form.as_p }} renders each field with its label, its previous value and its validation errors in paragraphs. form.is_valid() runs all the checks; if anything fails, the final render re-shows the form with the messages next to each field — the complete cycle that in Flask you programmed with try/except and 400 responses, here out of the box.

clean_price: custom validation

Automatic validation covers types and lengths, but "the price must be positive" is our own business rule — the same one M5's dataclass defended and 10-03's POST checked by hand. In Django you hook it in with a clean_<field> method on the form:

class BookForm(forms.ModelForm):
    class Meta:
        model = Book
        fields = ["title", "price", "stock"]

    def clean_price(self):
        price = self.cleaned_data["price"]
        if price <= 0:
            raise forms.ValidationError("Price must be greater than zero.")
        return price

Django calls clean_price during is_valid(); if you raise ValidationError (an exception with a trade, like M7's), the message appears next to the field. The user sees "Price must be greater than zero." beside the box — without you writing a single line of error presentation.

POST-redirect-GET and flash messages

Look at the add_book view again: after saving, it doesn't render a template — it does a redirect(...). This is the POST-redirect-GET pattern and it prevents an embarrassing classic: if you answer the POST with a page and the user presses F5, the browser resends the POST and the book gets created twice. By redirecting, what gets reloaded is a harmless GET. Engrave it: after a successful POST, always redirect.

And how do we announce the success if we're leaving for another page? With flash messages: messages.success(...) leaves a note that survives exactly one request. In base.html, a slot to display them:

{% if messages %}
    {% for message in messages %}<p class="notice">{{ message }}</p>{% endfor %}
{% endif %}

The user lands on the freshly created book's page with "'Faust II' added to the catalog." at the top. Entry → redirect → confirmation: the gold standard of web forms.

CSRF: what that token protects you from

I owed you the explanation of {% csrf_token %}. CSRF (Cross-Site Request Forgery) is a real attack: Marta has signed in to Papyrus; she later visits a malicious website containing a hidden form aimed at https://papyrus.example/add/ that submits itself. Without protection, Marta's browser — carrying her session credentials — would execute the POST without her knowing: the malicious site would have acted in her name.

The defense: Django includes in every legitimate form a secret, unrepeatable token (that's what {% csrf_token %} prints) and rejects with 403 Forbidden any POST that doesn't carry it. The attacking site cannot know the token, so its fake form dies at the door. For you the rule is simple: every <form method="post"> carries {% csrf_token %}; if you forget the token, the 403 will remind you. This is the "security out of the box" that 10-01's framework table promised, working without you configuring it.

Flask vs Django: same functionality, two roads

Papyrus web now exists in duplicate, and that duplication is the module's best summary:

Need Flask road (10-02/10-03) Django road (10-04/10-05)
Define routes @app.route (decorator, 08-02) path(...) in urls.py, with a name
View Function returning HTML/JSON Function (request) → render/redirect
Data papyrus package + M6 JSON Book model + ORM + SQLite
Query find_book() (M3), M4 dict Book.objects.filter/get (QuerySets)
"Doesn't exist" → 404 BookNotFoundError + errorhandler (M7) Book.DoesNotExist + get_object_or_404
Templates Jinja2: {{ }}, {% %}, inheritance Django engine: almost identical, stricter
Forms and validation By hand: get_json(), try/except, 400 ModelForm + clean_<field>
Management panel You'd have to build it Admin out of the box (three lines)
Persist an entry save_catalog() (M6) form.save()

The moral is not "Django wins": it's that Flask taught you what frameworks do and Django taught you to accept it ready-made. Knowing both, you'll choose with judgment — and you'll learn any other framework (FastAPI, or whatever triumphs five years from now) in days, because the concepts are these.

Deployment: an honest mention and the module's close

The last mile remains, and this course owes it to you honestly rather than pretending it doesn't exist: both flask run and runserver are development servers. Publishing Papyrus on the real internet involves an application server (gunicorn is the classic) behind a web server (nginx), DEBUG = False in settings.py (with DEBUG = True an error page shows your insides to the world), the SECRET_KEY and credentials out of the code — in environment variables — ALLOWED_HOSTS configured, and HTTPS. Each piece has excellent documentation and none fits in this module: when the day comes, search for "Django deployment checklist". Today it's enough to know the list exists and what's on it.

And with that, module 9's cliffhanger is settled: Papyrus no longer lives only on Ana's computer. Luis checks stock from his browser at home; Julia opens Hamlet's page on her phone and sees the member price; Marta manages the inventory from the admin without asking anyone for anything. The neighborhood bookshop has a shop window on the net.

Common Mistakes and Tips

  • TemplateDoesNotExist: check the double folder — the template must be at catalog/templates/catalog/home.html and be referenced as "catalog/home.html". It's stumble number one.
  • 403 Forbidden on submitting a form: {% csrf_token %} is missing inside the <form>. Now you know that error is Django protecting you, not pestering you.
  • Using bare objects.get() in views: if it doesn't exist, an uncaught Book.DoesNotExist = a 500 error. In views, get_object_or_404; naked get(), only when you control the exception yourself.
  • Answering a POST with render instead of redirect: it works... until someone reloads the page and duplicates the entry. Successful POST → redirect, no exceptions.
  • Logic in templates: if you find yourself missing the ability to compute in the template, that's the signal that the code belongs in the model (like member_price) or the view. Django limits you on purpose.
  • {{ book.in_stock() }} with parentheses: a template syntax error. In Django's engine methods are invoked without parentheses — which is why only methods with no required arguments work.

Exercises

Exercise 1: the search feature, Django edition

Add the view search (URL /search/, name search) that reads request.GET.get("title", "") and uses Book.objects.filter(title__icontains=term) to return partial matches to the template. Add the GET form to home.html. Notice: the Flask version from 10-02 only found exact titles — here the ORM gives you partial search for free.

Exercise 2: we don't sell for free or at a loss

Extend BookForm with clean_stock rejecting negative stock with the message "Stock cannot be negative." and check in the browser that the form shows it next to the field without losing the other values typed in.

Exercise 3: available only

Add the view available (URL /available/, name available) that lists only books with stock, reusing the home.html template (pass it the filtered QuerySet as books). Link it from base.html with {% url %}.

Solutions

Exercise 1:

def search(request):
    term = request.GET.get("title", "").strip()
    books = Book.objects.filter(title__icontains=term) if term else []
    return render(request, "catalog/search.html",
                  {"term": term, "books": books})
<form action="{% url 'search' %}" method="get">
    <input type="text" name="title" placeholder="Title...">
    <button type="submit">Search</button>
</form>

request.GET is Flask's request.args. icontains makes "ody" find The Odyssey: compare with find_book, which demanded the title spot-on.

Exercise 2:

def clean_stock(self):
    stock = self.cleaned_data["stock"]
    if stock < 0:
        raise forms.ValidationError("Stock cannot be negative.")
    return stock

On resubmitting the invalid form, Django repaints each field with what the user typed and the error beneath the guilty field: all of that user experience comes with {{ form.as_p }}.

Exercise 3:

def available(request):
    books = Book.objects.filter(stock__gt=0).order_by("title")
    return render(request, "catalog/home.html", {"books": books})
<header>
    <a href="{% url 'home' %}">Papyrus</a> ·
    <a href="{% url 'available' %}">Available only</a>
</header>

Reusing home.html with a different context is MTV in its purest form: same presentation, different data. If the design changes tomorrow, you touch a single file.

Conclusion

Module 10 kept its promise: Papyrus left Ana's computer and now opens from Luis's browser and Julia's phone. It did so twice, and that was the big lesson. With Flask you saw every piece exposed: @app.route as the 08-02 decorator, Jinja2 filling in HTML, asdict() and jsonify serving the catalog as JSON, and BookNotFoundError translated into a 404 with an errorhandler — module 7 speaking HTTP. With Django you accepted the batteries: the Book model that persists on its own, QuerySets instead of loops, get_object_or_404, a ModelForm that validates with clean_price the way your dataclass validated, POST-redirect-GET, flash messages and a CSRF shield that protects you without asking — plus the admin that turned Marta into the catalog's manager with three lines. Same functionality, two roads, and you with the judgment to choose. But opening the bookshop to the world has a consequence Ana doesn't see coming yet: a website generates data. Every sale, every search, every visit to a book page leaves a trail — which titles get looked at but not bought? Which day of the week sells most? How much stock should she order ahead of Sant Jordi, Catalonia's book day? Answering that is no longer programming the shop: it's understanding its numbers. That trade is called data science, its tools are NumPy, pandas and Matplotlib, and it is exactly module 11.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved