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
- QuerySets: asking the database in Python
Book.DoesNotExistandget_object_or_404- Views with templates:
renderand the context - Named URLs
- Django templates: inheritance,
{% url %}and filters - The book page: stock and member price
- Forms with
ModelForm: the book entry clean_price: custom validation- POST-redirect-GET and flash messages
- CSRF: what that token protects you from
- Flask vs Django: same functionality, two roads
- 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 exception — Book.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 doneRecognize 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:
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 priceDjango 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 atcatalog/templates/catalog/home.htmland be referenced as"catalog/home.html". It's stumble number one.403 Forbiddenon 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 uncaughtBook.DoesNotExist= a 500 error. In views,get_object_or_404; nakedget(), only when you control the exception yourself. - Answering a POST with
renderinstead ofredirect: 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 stockOn 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
