Skip to content

4. Templates and Forms

Until now, views generated their own responses: text, JSON, and a few HTML tags written as a string. In a MVC application, generating the page is the role of the V, which [Django] calls a template: a file that describes the page, with placesholders for the data passed to it by the view—the template context (the M model of MVC; see introduction).

[Django] has its own template engine and its own template language, DTL (Django Template Language). It can also use other engines, such as [Jinja2], the one used by [Flask] (setting [TEMPLATES]); this document focuses on DTL, which all [Django] applications use. A template is a text file, in this case HTML (given the extension [.html]), stored in a folder named [templates], in which three types of tags are interpreted on the server:

  • [{{ variable }}]: the value of a context variable, inserted into the page;
  • [{% tag %}]: a tag that controls the engine: condition ([{% if %}]), loop ([{% for %}]), template inheritance ([{% extends %}], [{% block %}]), inclusion ([{% include %}]), creation of a URL ([{% url %}])...;
  • [{# comment #}]: a comment that does not appear on the generated page. It fits on a single line; a multi-line comment is written between the [{% comment %}] and [{% endcomment %}] tags.

Everything else is copied verbatim. The expressions are not written in [Python], but in the deliberately limited language of DTL: [model.title] refers to the key [title] in a dictionary, or the attribute [title] of an object, or the element [title] in a list ([Django] tries them in that order), and, if it is a method, [Django] calls it without any parameters; filters transform a value ([name|upper] capitalizes a name); tags perform actions. That’s it: DTL has no arithmetic operators, no function calls with parameters, and no assignments. This is by design: the creators of [Django] wanted templates to be written by graphic designers, without the risk of application logic creeping into the pages. A calculation is therefore performed in [Python]—either in the view or in a method of a context object—and the template simply displays the result. The languages of [Twig] ([Symfony]) and [Jinja2] ([Flask]), which were inspired by it, are more permissive: they perform calculations ([total / count]) and call functions with parameters. Finally, a template is not re-parsed with every request: upon its first use, [Django] parses it and transforms it into a list of nodes—a [Python] object—which it keeps in memory and reuses thereafter (in debug mode, it re-parses it if it has been modified). We’ll see what this list looks like.

4.1. Example 13: A First Template

13-first-view/
├── config/
│   ├── settings.py
│   ├── urls.py
│   └── ...
├── pages/
│   ├── __init__.py
│   ├── urls.py
│   ├── viewmodels.py
│   └── views.py
├── templates/
│   └── pages/
│       ├── index.html
│       └── unknown.html
└── manage.py

4.1.1. The configuration: config/settings.py

The template engine is configured via the setting [TEMPLATES]:

# config/settings.py: the configuration of example 13

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-_ph!-_ox*o_&n8o2i=ykjwic(j9!mwf!cc2(()%nzdrs9ssai_"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "en"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "pages",
]

# the TEMPLATE ENGINES: here a single one, Django's (the DTL, Django Template
# Language), which looks for the templates in the project's templates folder
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
    },
]
  • Lines 19–24: [TEMPLATES] is a list of template engines, each described by a dictionary. Here, there is only one: [BACKEND] refers to the engine [Django] (DTL); [DIRS] is the list of folders where it searches for templates: the [templates] folder of the project. The configuration can also instruct the engine to look for templates in the [templates] folder of each application within the [INSTALLED_APPS] configuration ([APP_DIRS: True], example 18): this is how a reusable application provides its own templates.

The templates are stored in a subfolder named [pages], which is named after the application: this is standard practice, which prevents two applications from each having a [index.html] template and one from overwriting the other.

4.1.2. The template context: pages/viewmodels.py

# pages/viewmodels.py: the view's model, the data the Python view passes to the template

from dataclasses import dataclass


@dataclass(frozen=True)
class PageModel:
    title: str
    message: str
    time: str

A template’s context is a [Python] dictionary. Here, it will have only one entry, [model], an immutable object of the class [PageModel] (see Example 03), which contains the three pieces of information that the template will display. There is no requirement to define a template’s context using a class: one could pass three separate variables to the template. However, a class documents what the template expects and allows the editor to verify the view’s code. This is the approach that the case study will follow, where each important page has its own template class.

4.1.3. Views: pages/views.py

# pages/views.py: views that return a page built by a TEMPLATE

from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.http import require_GET

from .viewmodels import PageModel


# render: the template engine builds the page from the template
# templates/pages/index.html and from the CONTEXT, a dictionary of the variables made
# available to the template (here a single one, model); the produced page is the body
# of the response
@require_GET
def index(request):
    model = PageModel(
        "My first template",
        "This page was built by Django, on the server.",
        # the time, in the application's time zone (TIME_ZONE setting)
        timezone.localtime().strftime("%H:%M:%S"))
    return render(request, "pages/index.html", {"model": model})


# the same template with another model; or another template, with the 404 code
@require_GET
def hello(request, name):
    if name == "unknown":
        # status: the code of the response
        return render(request, "pages/unknown.html", {"name": name}, status=404)
    model = PageModel(
        f"Hello {name}",
        "This time, the title of the page comes from the URL.",
        timezone.localtime().strftime("%H:%M:%S"))
    return render(request, "pages/index.html", {"model": model})
  • Lines 16–20: The view constructs the model. [timezone.localtime()], from the [django.utils.timezone] module, returns the current time in the application’s time zone (settings [TIME_ZONE], [Europe/Paris]); [strftime] formats it: [%H] for hours, [%M] for minutes, [%S] for seconds;
  • Line 21: The shortcut [render] (from the [django.shortcuts] module, example 04) causes the template engine to generate the page. It receives the request, the template name (the file path, relative to one of the template folders), and the context: a dictionary, where each key becomes a template variable—in this case, a single one: [model]. [render] returns a [HttpResponse] object, whose body is the rendered page (of type [text/html]);
  • lines 27–29: another template, with a different variable ([name]). The parameter [status] of [render] specifies the response code: in this case, 404, for a custom error page;
  • lines 30–34: the same template as the view [index], with a different model. This is the benefit of separating views from templates: a single template can be used for multiple views.

The routes ([pages/urls.py]) associate [/] with the view [index], and [/hello/<str:name>] with the view [hello].

4.1.4. The template: templates/pages/index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  {# {{ ... }}: the value of a variable, inserted into the page #}
  <title>{{ model.title }}</title>
</head>
<body>
  <h1>{{ model.title }}</h1>
  <p>{{ model.message }}</p>
  <p>Page built at {{ model.time }}.</p>
</body>
</html>
  • line 5: a comment from DTL;
  • line 6: [{{ model.title }}] inserts the value of the [title] attribute of the [model] variable. The dot is the access operator for DTL, whereas [Python] would write [model.title] or [model["title"]], as appropriate.

Everything inserted by [{{ }}] is escaped: the characters [<], [>], [&], ["], and ['] are replaced by their entities HTML. This is the role that function [format_html] played in Example 09, but this time it is automatic: a template therefore cannot inadvertently insert the HTML provided by a user into the page.

4.1.5. The template: templates/pages/unknown.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Unknown</title>
</head>
<body>
  <h1>Nobody is called [{{ name }}] here.</h1>
</body>
</html>

A template does not have to accept an object: the variable [name] is a string.

4.1.6. Execution

Image

Image

The time displayed is that of the application's time zone. The raw response shows how the engine works: the [{{ }}] tags have been replaced with their values, and the comment has disappeared (leaving behind an empty line with its indentation).

curl -i http://localhost:8000/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">

  <title>My first template</title>
</head>
<body>
  <h1>My first template</h1>
  <p>This page was built by Django, on the server.</p>
  <p>Page built at 19:28:44.</p>
</body>
</html>

The “custom” error page, followed by a name containing HTML and an apostrophe ([<b>martin'], encoded as URL):

curl -i http://localhost:8000/hello/unknown
curl "http://localhost:8000/hello/%3Cb%3Emartin'"
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Unknown</title>
</head>
<body>
  <h1>Nobody is called [unknown] here.</h1>
</body>
</html>
<!doctype html>
...
  <title>Hello &lt;b&gt;martin&#x27;</title>
</head>
<body>
  <h1>Hello &lt;b&gt;martin&#x27;</h1>
  <p>This time, the title of the page comes from the URL.</p>
...

The tag [<b>] was escaped, without the view or the template having to do anything (the apostrophe was also escaped: [&#x27;]). Here, finally, is the template as [Django] stores it in memory. The command [python manage.py shell] opens a console [Python] where [Django] is ready; the option [-c] causes it to execute a few instructions:

python manage.py shell -c "from django.template.loader import get_template; print(*get_template('pages/index.html').template.nodelist, sep='\n')"
<TextNode: '<!doctype html>\n<html lan'>
<TextNode: '\n  <title>'>
<Variable Node: model.title>
<TextNode: '</title>\n</head>\n<body>\n '>
<Variable Node: model.title>
<TextNode: '</h1>\n  <p>'>
<Variable Node: model.message>
<TextNode: '</p>\n  <p>Page built at '>
<Variable Node: model.time>
<TextNode: '.</p>\n</body>\n</html>\n'>

The template has become a list of nodes: pieces of text ([TextNode], whose display is truncated), copied as-is, and variables ([VariableNode]), whose values are looked up in the context and then escaped. The comment left no nodes. Generating the page involves iterating through this list and reassembling the text fragments. A template is therefore simply a safer and more readable way to write a function [Python] that generates HTML.

4.2. Example 14: The Template Language

This example combines the main constructs of DTL into a single template.

4.2.1. The Model and the View

The template model is described by three immutable data classes:

# basics/viewmodels.py: the model of the template of example 14

from dataclasses import dataclass


@dataclass(frozen=True)
class Address:
    street: str
    city: str


@dataclass(frozen=True)
class Product:
    name: str
    price: float
    in_stock: bool


@dataclass(frozen=True)
class BasicsModel:
    title: str
    name: str
    # str | None: a string, or None
    nickname: str | None
    html: str
    logged_in: bool
    currency: str
    products: list[Product]
    cart: list[Product]
    address: Address

    # @property: a method that is read like an attribute (model.total). The
    # template language cannot compute: the calculations are done in Python
    @property
    def total(self) -> float:
        return sum(p.price for p in self.products)

    @property
    def average_price(self) -> float:
        return self.total / len(self.products)
  • line 24: [str | None]: a string, or [None] (example 06);
  • lines 28–29: lists of [Product] objects;
  • lines 34–40: Since DTL cannot perform calculations, the calculations required by the template (total price, average price) are methods of the model. The decorator [@property] turns a method into a property: it is read as an attribute, without parentheses ([model.total]), and this is how DTL will see it. For simplicity, the prices here are of type [float] (Example 06 showed why we prefer whole cents).

The model contains values of various types: strings, [None], Booleans, lists of objects, empty lists, and objects containing other objects.

# basics/views.py: a template that uses the main constructs of Django's language

from django.shortcuts import render
from django.views.decorators.http import require_GET

from .viewmodels import Address, BasicsModel, Product


@require_GET
def index(request):
    # NAMED arguments: each value is associated with an attribute by its name
    model = BasicsModel(
        title="Template basics",
        name="Martin",
        nickname=None,
        html="<b>this is HTML</b>",
        logged_in=True,
        currency="€",
        products=[
            Product("Keyboard", 25, True),
            Product("Mouse", 12.5, False),
            Product("Screen", 149, True),
        ],
        cart=[],
        address=Address("12 rue des Lilas", "Angers"))
    return render(request, "basics/index.html", {"model": model})

The view constructs the model using named parameters (see Example 02): each value is preceded by the name of the attribute it is intended for, which makes the call readable despite the number of parameters.

4.2.2. The template: templates/basics/index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{{ model.title }}</title>
</head>
<body>
  <h1>{{ model.title }}</h1>
  {% comment %}
    1. display a value of the model; None is displayed... None; the default filter
    replaces an empty value (None, "", 0, empty list...). A {# #} comment fits
    on a single line; this one, on several, is written between comment and endcomment
  {% endcomment %}
  <p>1. Name: {{ model.name }} - nickname: [{{ model.nickname }}] [{{ model.nickname|default:"none" }}]</p>
  {# 2. {{ }} escapes the HTML; the safe filter inserts it as is #}
  <p>2a. {{ model.html }}</p>
  <p>2b. {{ model.html|safe }}</p>
  {# 3. a condition #}
  {% if model.logged_in %}
    <p>3. You are logged in.</p>
  {% else %}
    <p>3. You are not logged in.</p>
  {% endif %}
  {# 4. a loop: the forloop variable describes the state of the loop #}
  <p>4. Our products:</p>
  <ul>
    {% for product in model.products %}
      <li>no. {{ forloop.counter0 }}: {{ product.name }}, {{ product.price }} {{ model.currency }}
        {{ product.in_stock|yesno:"(in stock),(sold out)" }}
        {% if forloop.first %} - the first{% endif %}
        {% if forloop.last %} - the last, seen by {{ model.name }}{% endif %}
      </li>
    {% endfor %}
  </ul>
  {# 5. the empty list: the empty block of a for loop #}
  <p>5. Your cart:
    {% for p in model.cart %}<span>{{ p.name }}</span>{% empty %} it is empty.{% endfor %}
  </p>
  {% comment %}
    6. with: a template variable; the length filter counts the products;
    floatformat rounds, and writes the number according to the language (LANGUAGE_CODE setting)
  {% endcomment %}
  {% with count=model.products|length %}
    <p>6. {{ count }} products, for a total of {{ model.total }} {{ model.currency }};
      average price: {{ model.average_price|floatformat:2 }} {{ model.currency }}</p>
  {% endwith %}
  {# 7. an object within an object, and a filter that converts to uppercase #}
  <p>7. Address: {{ model.address.street }}, {{ model.address.city|upper }}</p>
  {# 8. verbatim: a text Django copies without interpreting it #}
  <p>8. Braces displayed as they are: {% verbatim %}{{ model.name }}{% endverbatim %}</p>
</body>
</html>
  • Lines 9–13: a multi-line comment;
  • line 14: a value [None] is displayed... [None]: this is the text representation of [None]. The filter [default] replaces an “empty” value ([None], empty string, 0, empty list...) with a default value. A filter is applied using a vertical bar and may take an optional parameter following a colon: [value|filter:"parameter"]. A variable that does not exist at all in the context ([model.unknown]) would be displayed as an empty string, without an error;
  • lines 16–17: [{{ }}] escapes HTML (2a); the filter [safe], on the other hand, inserts the value as-is, without escaping (2b). [safe] is reserved for HTML generated by the application itself, never for user input. The [{% autoescape off %}] tag disables escaping for an entire section of the template;
  • lines 19–23: a condition: [{% if %}], [{% elif %}], [{% else %}], [{% endif %}]. Each block is closed by a [{% end... %}] tag. A condition can use the operators [==], [!=], [<], [>], [<=], [>=], [in], [not], [and], [or];
  • lines 27–33: a loop [{% for ... in ... %}] ... [{% endfor %}]. Within the loop, the special variable [forloop] describes the loop’s state: [forloop.counter0] (the iteration number, starting at 0), [forloop.counter] (starting at 1), [forloop.first], [forloop.last] (true on the first and last iterations), [forloop.revcounter] (the number of remaining iterations). The variables outside the loop ([model.name], [model.currency]) remain accessible;
  • Line 29: The [yesno] filter selects a text based on whether a value is true or false: its two texts (a third, optional text would be used for [None]) are separated by a comma. This is the equivalent, in DTL, of a conditional expression;
  • line 37: a [for] loop can have a [{% empty %}] block, which is executed if the list is empty. This is simpler than a [{% if %}] that would test the length of the list;
  • Lines 43–46: the [{% with %}] tag creates a variable that is valid until [{% endwith %}]: in this case, the number of products, calculated by the [length] filter (the number of elements in a list or characters in a string);
  • Lines 44–45: the calculated properties of the model. The filter [floatformat] rounds a number—[floatformat:2]—to two decimal places. It formats the number according to the conventions of the application’s language ([LANGUAGE_CODE]): the decimal point is formatted for English. All numbers inserted into a page are formatted this way: [Django] formats numbers and dates according to the active language (in French, [12.5] would become [12,5]);
  • line 48: the period passes through objects ([model.address.city]), and the filter [upper] capitalizes a string (while correctly handling accented characters);
  • line 50: the [{% verbatim %}] tag copies its content without interpreting it: this is useful when a page contains curly braces intended for a [JavaScript] framework.

The DTL has about sixty filters ([lower], [capfirst], [title], [truncatechars], [join], [slice], [first], [last], [date], [time], [linebreaks], [urlize], [pluralize], [add], [divisibleby], [escape], [json_script]...) and about thirty tags ([if], [for], [with], [url], [now], [cycle], [firstof], [include], [extends], [block], [load], [csrf_token], [spaceless]...); The applications for [Django] add more ([static], [translate]...), and the application can write its own (Example 16).

4.2.3. Execution

Image

The generated page:

curl http://localhost:8000/
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Template basics</title>
</head>
<body>
  <h1>Template basics</h1>

  <p>1. Name: Martin - nickname: [None] [none]</p>

  <p>2a. &lt;b&gt;this is HTML&lt;/b&gt;</p>
  <p>2b. <b>this is HTML</b></p>


    <p>3. You are logged in.</p>


  <p>4. Our products:</p>
  <ul>

      <li>no. 0: Keyboard, 25 €
        (in stock)
         - the first

      </li>

      <li>no. 1: Mouse, 12.5 €
        (sold out)
...
  <p>5. Your cart:
     it is empty.
  </p>


    <p>6. 3 products, for a total of 186.5 €;
      average price: 62.17 €</p>


  <p>7. Address: 12 rue des Lilas, ANGERS</p>

  <p>8. Braces displayed as they are: {{ model.name }}</p>
</body>
</html>

The comments and tags have disappeared, but they leave behind their spaces and line breaks: [Django] does not remove any whitespace. This has no effect: the browser ignores this whitespace. (The [{% spaceless %}] tag would remove the whitespace between the HTML tags in a template section.) The numbers have been written in the English style ([12.5], [186.5]), even though the template did not require it.

4.3. Example 15: Parent template, fragments, static files

The pages of a website usually share the same structure: a header, a navigation bar, a footer, and the same style sheets. Rather than repeating this structure in every template, it is written once in a parent template (also called a layout), which the pages extend. This example also uses [Bootstrap], the most widely used CSS framework, for the layout.

15-layout/
├── config/
│   ├── contexts.py
│   ├── settings.py
│   └── ...
├── pages/
│   ├── __init__.py
│   ├── viewmodels.py
│   └── views.py
├── static/
│   └── css/
│       └── styles.css
├── templates/
│   ├── fragments/
│   │   └── navbar.html
│   ├── pages/
│   │   ├── home.html
│   │   ├── raw.html
│   │   └── team.html
│   ├── layout.html
│   └── macros.html
└── manage.py

4.3.1. Static Files

A HTML page references other files: style sheets, [JavaScript] scripts, and images. These files, which the server sends as-is, are called static files. With [Django], they are stored in [static] folders (the project folder and the application folders), and the [django.contrib.staticfiles] application manages them. The configuration:

# config/settings.py: the configuration of example 15

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-u0g1t-=6%v^*y#i*czmc@ge#-yk6m!jj13lte=0yw_pa6y+2m4"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "en"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    # Django's application that manages the STATIC files (style sheets,
    # images, scripts): the {% static %} tag, and their serving by runserver
    "django.contrib.staticfiles",
    "pages",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "OPTIONS": {
            # the CONTEXT PROCESSORS: functions that add variables to the
            # context of ALL the templates; request: the current request
            "context_processors": [
                "django.template.context_processors.request",
                "config.contexts.site",
            ],
        },
    },
]

# the URL of the static files: {% static 'css/styles.css' %} -> /static/css/styles.css
STATIC_URL = "static/"
# the folders where Django looks for the static files: the example's one, and
# Bootstrap's one, shared by examples 15 to 28
STATICFILES_DIRS = [
    BASE_DIR / "static",
    BASE_DIR.parent / "common" / "static",
]
  • lines 14–19: the [django.contrib.staticfiles] application, one of the applications included with [Django];
  • lines 21–34: the template engine, with two context processors, described later;
  • line 37: [STATIC_URL], the prefix for the URL static files: the file [css/styles.css] will be available in URL and [/static/css/styles.css];
  • Lines 40–43: [STATICFILES_DIRS], the list of static file folders for the project (in addition to the [static] folders for the applications). The first one is the one from the example. The second is the [examples/common/static] folder, which contains [Bootstrap]: rather than including a copy in each example, examples 15 through 28 share it (see introduction). [Django] searches for a file in all these folders, in order, and returns the first one found.

In debug mode, the development server serves the static files itself, using the [staticfiles] application: a URL that begins with [/static/] is not handled by the application’s routes, but by the static file handler. In production ([DEBUG = False]), it no longer does this: that is not the role of a [Python] application. The command [python manage.py collectstatic] then copies all static files from the project and applications into a single folder (the setting [STATIC_ROOT]), which the web server ([nginx], [Apache]...) serves directly, or that is served by a library such as [WhiteNoise] (see case study).

/* a few settings in addition to Bootstrap */
body {
  background-color: #f5f7fa;
}

/* the footer of the layout */
.footer {
  margin-top: 2rem;
  font-size: 0.85rem;
  color: #6c757d;
}

/* the error messages, at the bottom of the page, on a light pink background */
.error-area {
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef;
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

A few additional CSS rules in addition to [Bootstrap]: a light gray background, the footer style, and the style for an error area that will be used in the following examples.

4.3.2. The parent template: templates/layout.html

<!doctype html>
{# the PARENT template of the pages: the pages EXTEND it, and fill its blocks #}
{% load static %}
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {# block: a slot that the page can fill; here with a default content #}
  <title>{% block title %}Home{% endblock %} - {{ site_name }}</title>
  {# static: the URL of a static file #}
  <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
  <link rel="stylesheet" href="{% static 'css/styles.css' %}">
</head>
<body>
  <div class="container py-3">
    {# insertion of the fragments/navbar.html template #}
    {% include "fragments/navbar.html" %}
    <main>
      {# the content of the page requested by the view is inserted here #}
      {% block content %}{% endblock %}
    </main>
    {# request: the current request; site_name: a global variable (see config/contexts.py) #}
    <footer class="footer">Page [{{ request.path }}] - © {% now "Y" %} {{ site_name }}</footer>
  </div>
</body>
</html>
  • Line 3: [{% load static %}] loads a tag library: that of the [staticfiles] application, which provides the [{% static %}] tag. Tags and filters that are not part of the core of DTL are stored in libraries, which a template must load before using them;
  • line 7: the [viewport] tag instructs mobile browsers to display the page at the screen’s width, which is what [Bootstrap] expects;
  • line 9: the [{% block %}] tag marks a named slot, which pages that extend the template can fill by defining a block with the same name. The content written between [{% block title %}] and [{% endblock %}] is the default content for the placeholder, used by a page that does not define this block. [site_name] is a global variable present in all templates (see [config/contexts.py], below);
  • Lines 11–12: [{% static 'path' %}] generates URL from a static file: [STATIC_URL] followed by the path. Writing the URL files by hand ([/static/css/styles.css]) would also work, but [{% static %}] adapts if you change [STATIC_URL] (to serve the files from another site, for example), or if you add a hash of their content to the filenames for caching purposes (case study);
  • line 17: [{% include %}] inserts the content of another template, in this case the navigation bar. The included template sees the variables from the template that includes it;
  • line 20: the placeholder that each page fills with its own content;
  • line 23: the variable [request] is the current request ([request.path]: the path of the requested URL). It is present in all templates thanks to the context processor [request] (see below). The tag [{% now "Y" %}] writes the current date in the following format: [Y], with the year as four digits.

The [container] class of [Bootstrap] centers the content and limits its width; [py-3] adds vertical padding: [Bootstrap] provides hundreds of classes of this type, which are combined in the [class] attribute.

4.3.3. Global variables: config/contexts.py

1
2
3
4
5
6
7
# config/contexts.py: a context processor

# Django calls it for each template rendered with the request (render(request, ...));
# the dictionary it returns is added to the context: site_name is a GLOBAL variable,
# present in all the templates
def site(request):
    return {"site_name": "My site"}

A context processor is a function that receives the request and returns a dictionary of variables to be added to the context of all templates. [Django] calls the processors from the [context_processors] setting (lines 28–31 of the file [config/settings.py]) every time a template is generated using the request (as is the case with [render(request, ...)]). The [django.template.context_processors.request] processor, included with [Django], adds the variable [request]; ours adds [site_name]. Other applications of [Django] provide their own: messages (example 20), language (example 22)... A processor is called for each template generated: it must be fast.

4.3.4. The navigation bar: templates/fragments/navbar.html

{# a fragment: a piece of page, inserted by include #}
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">{{ site_name }}</span>
  <div class="navbar-nav">
    {# url: the URL of a route, designated by its name #}
    <a class="nav-link" href="{% url 'home' %}">Home</a>
    <a class="nav-link" href="{% url 'team' %}">Team</a>
    <a class="nav-link" href="{% url 'raw' %}">Page without parent template</a>
  </div>
</nav>

A fragment: a piece of a page that has no meaning on its own. The classes [navbar], [navbar-brand], and [nav-link] are those of the navigation bar in [Bootstrap]. The links are generated by the [{% url %}] tag (line 6), based on the route names (example 04): [{% url 'team' %}] yields [/team]. The route parameters follow the name: [{% url 'contacts_details' contact.id %}] (example 21).

4.3.5. Named fragments: templates/macros.html

{# NAMED FRAGMENTS (partialdef): reusable pieces of page, stored in a same file #}

{# a Bootstrap card, with a name and a role #}
{% partialdef card %}
  <div class="card">
    <div class="card-body">
      <h5 class="card-title">{{ name }}</h5>
      <p class="card-text">{{ role }}</p>
    </div>
  </div>
{% endpartialdef %}
  • lines 4–11: The [{% partialdef %}] tag, introduced with [Django] 6.0, defines a named fragment (partial): a portion of a template, in this case a card ([card]) from [Bootstrap]. A file can contain several of these. Defined in this way, the fragment is not displayed where it is written: it is displayed elsewhere, using [{% include "macros.html#card" %}] (the file name, a hash symbol, the fragment name), or, within the same file, using [{% partial card %}]. The fragment displays two variables, [name] and [role], which will be provided to it upon inclusion.

Students of the [Symfony] course will recognize how this course used the macros from [Twig]. There is one difference: a macro from [Twig] could only see its own parameters; an included fragment can see all the variables in the template that includes it, in addition to the ones passed to it. The keyword [only] ([{% include "..." with name="..." only %}]) restricts it to the latter. For a more complex component that requires [Python] code, an inclusion tag is written (Example 16).

4.3.6. The pages

{# this page EXTENDS the parent template: it only provides the content of its blocks #}
{% extends "layout.html" %}

{% block content %}
  <h1>Welcome</h1>
  <p>This page only contains its own content: the header, the navigation
    bar and the footer come from the parent template <code>layout.html</code>.</p>
  {# the [card] fragment of the macros.html file, with two variables written in place #}
  {% include "macros.html#card" with name="A fragment" role="included with variables written in place" %}
{% endblock %}
  • Line 2: The page extends the parent template: [Django] generates the parent page, replacing its blocks with those defined by the page. [{% extends %}] must be the first tag in the template (only a comment [{# #}] may precede it). In a template that extends another template, all content must be within blocks: text written outside of blocks does not appear in the generated page;
  • lines 4–10: the [content] block, which fills the slot of the same name. The page does not define the [title] block: the slot retains its default content, “Home”;
  • line 9: the inclusion of the fragment [card], with two variables specified after the keyword [with]. The HTML in the fragment is not escaped a second time: [Django] knows it is already safe HTML (the variables, however, were escaped within the fragment).
{% extends "layout.html" %}

{% block title %}Our team{% endblock %}

{% block content %}
  <h1>Our team</h1>
  <div class="row g-3">
    {% for member in members %}
      <div class="col-md-4">
        {# the variables of the fragment are any expressions #}
        {% include "macros.html#card" with name=member.name role=member.role %}
      </div>
    {% endfor %}
  </div>
{% endblock %}
  • line 3: the block [title], on a single line;
  • lines 7–14: one card per team member, in a grid of [Bootstrap] ([row], [col-md-4]: three columns on a medium or large screen, one column on a narrow screen);
  • line 11: the variables passed to the fragment can be any expressions.

A block in the parent can also be supplemented rather than replaced: [{{ block.super }}], written in the page block, inserts the content that the block had in the parent. A template can extend a template that itself extends another: inheritance has as many levels as needed. The view contains no new features:

# pages/views.py: three pages

from django.shortcuts import render
from django.views.decorators.http import require_GET

from .viewmodels import Member


@require_GET
def home(request):
    return render(request, "pages/home.html")


@require_GET
def team(request):
    members = [
        Member("Marie Pélissier", "Director"),
        Member("Jacques Bromard", "Developer"),
        Member("Philippe Jandot", "Graphic designer"),
    ]
    return render(request, "pages/team.html", {"members": members})


@require_GET
def raw(request):
    return render(request, "pages/raw.html")

Finally, the third page does not use the parent template:

<!doctype html>
{# this template does not extend the parent template: it is a complete page #}
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Raw page</title>
</head>
<body>
  <p>This page does not use the parent template: neither Bootstrap, nor navigation bar.</p>
  <p><a href="{% url 'home' %}">Back to the home page</a></p>
</body>
</html>

4.3.7. Execution

Image

Image

Image

Since the screenshot is narrow, the team cards are stacked one below the other. The team page, as it appears in the browser:

curl http://localhost:8000/team
<!doctype html>


<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <title>Our team - My site</title>

  <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
  <div class="container py-3">


<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">My site</span>
  <div class="navbar-nav">

    <a class="nav-link" href="/">Home</a>
    <a class="nav-link" href="/team">Team</a>
    <a class="nav-link" href="/raw">Page without parent template</a>
  </div>
</nav>

    <main>


  <h1>Our team</h1>
  <div class="row g-3">

      <div class="col-md-4">


  <div class="card">
    <div class="card-body">
      <h5 class="card-title">Marie Pélissier</h5>
      <p class="card-text">Director</p>
    </div>
  </div>

      </div>
...
  </div>

    </main>

    <footer class="footer">Page [/team] - © 2026 My site</footer>
  </div>
</body>
</html>

The browser received a complete page: the parent template, the navigation bar fragment, the page, and the cards were assembled on the server. The URL style sheets were calculated by [{% static %}]: these are URL specific to the site. You can verify that the style sheets are being served correctly (the [-I] option of [curl] displays only the response headers when sending a HEAD request):

curl -I http://localhost:8000/static/bootstrap/css/bootstrap.min.css
curl -I http://localhost:8000/static/css/styles.css
HTTP/1.1 200 OK
Content-Type: text/css
Content-Length: 232111
Content-Disposition: inline; filename="bootstrap.min.css"
Last-Modified: Sun, 27 Sep 2026 16:41:12 GMT

HTTP/1.1 200 OK
Content-Type: text/css
Content-Length: 435
Content-Disposition: inline; filename="styles.css"
Last-Modified: Sun, 27 Sep 2026 16:42:11 GMT

The first file comes from the [common/static] folder, the second from the [static] folder in the example: to the browser, there is no difference between them. The content type was inferred from the file extension ([text/css]): a browser will reject a stylesheet that is declared as something else. Finally, an attempt to access the static file folders:

curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:8000/static/..%2F..%2Fconfig%2Fsettings.py"
500

(the [-s -o /dev/null -w "%{http_code}\n"] options display only the response code; under [Windows], it displays [-o NUL]). The URL option requests the [config/settings.py] file from the example, moving up two folders (the [/] files are encoded as [%2F], so that neither [curl] nor the browser simplifies the path on its own). [Django] failed: the console displays the exception [SuspiciousFileOperation], with the message “The joined path (...) is located outside of the base path component.” Code 500 indicates an unhandled exception; the key point is that the file was not sent.

4.4. Example 16: Extending the template language

A template often needs to format values: an amount with its currency, a date written out in full, or text in uppercase. This code does not belong in the view (which does not handle presentation), and DTL does not allow it to be written in the template (see the introduction to this chapter). Instead, it is written in [Python], in a library of tags and filters: a module that adds its own filters and tags to the template language. Components that generate pieces of HTML from data—the inclusion tags—are also written in [Python].

16-extensions/
├── config/
│   ├── middleware.py
│   ├── settings.py
│   └── ...
├── pages/
│   ├── templatetags/
│   │   ├── __init__.py
│   │   └── formats.py
│   ├── __init__.py
│   ├── viewmodels.py
│   └── views.py
├── static/
│   └── css/
│       └── styles.css
├── templates/
│   ├── fragments/
│   │   └── navbar.html
│   ├── pages/
│   │   ├── home.html
│   │   └── products.html
│   ├── tags/
│   │   ├── badge.html
│   │   └── link.html
│   └── layout.html
└── manage.py

4.4.1. The library: pages/templatetags/formats.py

A tag library is a module contained in an application’s [templatetags] package (which must be declared in [INSTALLED_APPS]); the module name ([formats]) is the name that a template will assign to [{% load formats %}]:

# pages/templatetags/formats.py: a LIBRARY of extensions of the template
# language, loaded by {% load formats %}: two filters and two tags

from babel.dates import format_date
from babel.numbers import format_currency
from django import template
from django.utils.translation import get_language

# the object that registers the filters and tags of the library
register = template.Library()


# a FILTER: {{ product.price_cents|money }} or {{ ...|money:"USD" }}. An
# amount in cents, in a currency, in the format of the active language:
# 114990 -> "€1,149.90" in English, "1 149,90 €" in French. The Babel
# library knows the conventions of each country
@register.filter
def money(cents, currency="EUR"):
    return format_currency(cents / 100, currency, locale=get_language())


# a date in full words, in the active language: "Sunday, September 27, 2026"
@register.filter
def long_date(day):
    return format_date(day, format="full", locale=get_language())


# an INCLUSION TAG: {% link 'products' '/products' 'Products' %} displays the
# tags/link.html template, with the context the function returns.
# takes_context: the function also receives the context of the calling template
@register.inclusion_tag("tags/link.html", takes_context=True)
def link(context, route, path, text):
    # a link of the navigation bar, which receives the [active] class if the
    # current page is under the [path] path
    active = context["request"].path.startswith(path)
    return {"route": route, "text": text, "active": active}


# a Bootstrap badge: green if the product is in stock, red otherwise
@register.inclusion_tag("tags/badge.html")
def badge(stock):
    return {"stock": stock}
  • lines 4–5: two functions from the [Babel] library (see introduction), which knows the conventions for writing numbers, amounts, and dates for all countries (it uses data from the Unicode project CLDR);
  • line 7: [get_language] sets the active language for the current query ([fr], [en]); we’ll see who sets it;
  • line 10: the object [register], a [template.Library], stores the module’s filters and tags. The variable name is hard-coded: this is where [Django] looks for them;
  • Lines 17–19: A filter is a function, registered by the decorator [@register.filter] under its own name, [money]. Its first parameter is the filtered value; the second, optional parameter is the filter’s parameter in the template ([price|money:"USD"]): here, the currency, designated by its international code ([EUR], [USD]). The filter formats an amount received in cents (see example 06). The formatting depends on the language: in French, [1 149,90 €] (space to separate thousands, decimal comma, currency symbol after the amount); in English, [€1,149.90];
  • lines 23–25: date formatting: the format [full] writes the date in full, including the day of the week ([Sunday, September 27, 2026]);
  • lines 31–36: an inclusion tag, defined by [@register.inclusion_tag]: a function that calculates a context, and a template (here, [tags/link.html]) that displays it. In a template, [{% link 'products' '/products' 'Products' %}] calls the function with these three parameters, then inserts the template, built using the dictionary it returned. With [takes_context=True], the function receives the context of the calling template as its first parameter: it finds the query there and determines whether the link points to the current page ([startswith] checks the beginning of a string);
  • lines 40–42: a second inclusion tag, without the context: a badge.

A library can also provide “simple” tags ([@register.simple_tag]), which return a value, and complete tags, which parse their own content (such as [{% if %}]): this is less common. The templates for the two inclusion tags:

<a class="nav-link{% if active %} active{% endif %}" href="{% url route %}">{{ text }}</a>
{% if stock > 0 %}<span class="badge text-bg-success">{{ stock }} in stock</span>{% else %}<span class="badge text-bg-danger">sold out</span>{% endif %}

The link for the current page is assigned the class [active] by [Bootstrap], which highlights it. [{% url route %}] generates the URL for a route whose name is stored in a variable. The badge is green ([text-bg-success]) or red ([text-bg-danger]) depending on inventory. The package [templatetags] also has its file [__init__.py]; without it, it would not be a package.

4.4.2. The language of each request: config/middleware.py

# config/middleware.py: the language of each request

from django.utils import translation


class SetLanguage:
    def __init__(self, get_response):
        self.get_response = get_response

    # the ?lang=fr parameter of the URL, English otherwise
    def __call__(self, request):
        language = "fr" if request.GET.get("lang") == "fr" else "en"
        # activate: the ACTIVE language, which Django uses to format the
        # numbers and dates, and to translate its messages
        translation.activate(language)
        return self.get_response(request)

Each request has an active language, which, by default, is the same as the one specified in the [LANGUAGE_CODE] setting. Here, the [?lang=fr] parameter of URL selects French; English is the default language. A middleware component (example 03) sets it before each view using the [translation.activate] function: This is the language that [get_language] will return—the language in which [Django] formats numbers and dates and translates its own messages (such as form error messages). The active language is specific to the execution thread handling the request: two simultaneous requests may have different languages. It is not reset from one request to the next; this is why the middleware sets it for each request, even for English. The chapter on internationalization will replace this middleware with that of [Django] and [LocaleMiddleware], and will use this same language to translate the text.

4.4.3. Configuration: config/settings.py

# config/settings.py: the configuration of example 16

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-yw&p(@e00b!)6xtfnoy-goyep6e*8jhvk_o0aj8a&pizr94xrq"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "en"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

# the languages of the application
LANGUAGES = [
    ("fr", "Français"),
    ("en", "English"),
]

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    # the pages application contains a templatetags folder: its extensions of the
    # template language are available through {% load formats %}
    "pages",
]

MIDDLEWARE = [
    "config.middleware.SetLanguage",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
            ],
        },
    },
]

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
    BASE_DIR.parent / "common" / "static",
]
  • lines 15–18: the application’s languages;
  • lines 20–25: the [pages] application must be declared; [Django] searches for the [templatetags] packages within the [INSTALLED_APPS] applications;
  • Lines 27–29: the language middleware.

4.4.4. The view: pages/views.py

# pages/views.py: two pages

from datetime import date

from django.shortcuts import render
from django.views.decorators.http import require_GET

from .viewmodels import Product


@require_GET
def home(request):
    return render(request, "pages/home.html", {"today": date.today()})


@require_GET
def products(request):
    items = [
        Product("keyboard", 2500, 12),
        Product("mouse", 1250, 0),
        Product("screen", 114990, 3),
    ]
    # the calculation is done here, in Python: the template only has to display it
    total = sum(p.price_cents * p.stock for p in items)
    return render(request, "pages/products.html", {"products": items, "stock_total": total})

Views pass raw values to templates: a date ([date.today()], today’s date) and amounts in cents. Formatting is handled by the templates. The total inventory, however, is a calculation: it is calculated in [Python] (line 24), since DTL cannot perform calculations. The [Product] class combines a name, a price (in cents; see example 06), and a stock quantity.

4.4.5. The templates

{% load formats %}
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">Extensions</span>
  <div class="navbar-nav">
    {# 1. a condition written in the template; resolver_match: the route of the current request #}
    <a class="nav-link{% if request.resolver_match.url_name == 'home' %} active{% endif %}" href="{% url 'home' %}">Home</a>
    {# 2. our [link] tag #}
    {% link 'products' '/products' 'Products' %}
    {# 3. url builds the path of a route; the URL parameters are added by hand #}
    <a class="nav-link" href="{% url 'products' %}?lang=fr">Produits (fr)</a>
  </div>
</nav>
  • line 1: the fragment loads the library it needs on its own: [{% load %}] libraries are not passed on to included templates or to templates that extend a parent. Each template loads its own libraries;
  • line 6: the same as with the [link] tag, written directly in the template, with a condition. [request.resolver_match] describes the result of routing the current request: [url_name] is the name of the route. The tag avoids having to repeat the condition for each link;
  • line 8: the tag call, with its three parameters, separated by spaces;
  • line 10: [{% url %}] only generates the path: the parameters for URL are added manually ([?lang=fr]).
{% extends "layout.html" %}
{% load i18n formats %}
{% block title %}Home{% endblock %}

{% block content %}
  <h1>Home</h1>
  {# our long_date filter #}
  <p>Today is {{ today|long_date }}.</p>
  {# the active language, set by the SetLanguage middleware #}
  {% get_current_language as language %}
  <p>Formatting language: {{ language }}
    (<a href="{% url 'home' %}?lang=fr">in French</a>, <a href="{% url 'home' %}">in English</a>)</p>
{% endblock %}
  • Line 2: The page loads two libraries: [i18n], the internationalization library for [Django], and our own;
  • line 8: our filter [long_date];
  • Lines 10–11: The [{% get_current_language as language %}] tag, from the [i18n] library, stores the active language in a template variable.
{% extends "layout.html" %}
{% load formats %}
{% block title %}Products{% endblock %}

{% block content %}
  <h1>Products</h1>
  <table class="table table-sm w-auto">
    <thead><tr><th>Product</th><th>Price</th><th>Price in dollars</th><th>Stock</th></tr></thead>
    <tbody>
      {% for product in products %}
        <tr>
          {# a Django filter (upper), then our money filter, without then with parameter #}
          <td>{{ product.name|upper }}</td>
          <td>{{ product.price_cents|money }}</td>
          <td>{{ product.price_cents|money:"USD" }}</td>
          {# our [badge] tag builds the HTML of the badge #}
          <td>{% badge product.stock %}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
  <p>Stock total: {{ stock_total|money }}</p>
{% endblock %}
  • line 13: a [Django] filter;
  • lines 14–15: our [money] filter, first with its default currency, then with a parameter;
  • line 17: the call to the [badge] tag;
  • line 22: the total inventory, calculated by the view.

The parent template [layout.html] is a simplified version of the one in Example 15; its attribute [lang] receives the active language ([<html lang="{{ language }}">], after [{% get_current_language as language %}]).

4.4.6. Execution

Image

Image

The same page, with the parameter [?lang=fr]. Only the formatting of the numbers changes: the text itself is hard-coded in the templates. The chapter on internationalization will also translate the text.

Image

An excerpt from HTML produces:

curl "http://localhost:8000/products?lang=fr"
...
    <a class="nav-link" href="/">Home</a>
    <a class="nav-link active" href="/products">Products</a>
    <a class="nav-link" href="/products?lang=fr">Produits (fr)</a>
...
        <tr>
          <td>SCREEN</td>
          <td>1 149,90 €</td>
          <td>1 149,90 $US</td>
          <td><span class="badge text-bg-success">3 in stock</span>
</td>
        </tr>
...

4.5. Example 17: a GET form

A HTML form sends its fields to the server in one of two ways, depending on its [method] attribute:

  • [method="get"]: The fields are added to the URL as parameters ([/search?keyword=le&genre=novel]). This is the method used by a form that performs a query: a search or a filter. The URL containing the results can be bookmarked, shared, or reloaded safely;
  • [method="post"]: The fields are included in the request body (see Example 11). This is the method used by a form that modifies data: a registration, an addition, or a deletion.

This first form is a book search. It is hand-coded in HTML within the template: the following examples will use the [Django] form templates.

4.5.1. The template: books/viewmodels.py

# books/viewmodels.py: a book, and the model of the search template

from dataclasses import dataclass


@dataclass(frozen=True)
class Book:
    title: str
    author: str
    genre: str


# the model of the template: the input (to display it again), the genres, the results
@dataclass(frozen=True)
class SearchModel:
    keyword: str
    genre: str
    genres: list[str]
    results: list[Book]
  • Lines 14–19: The template contains the user’s input, which the form will display, the list of suggested genres, and the results.

4.5.2. The view: books/views.py

# books/views.py: a search form, sent by GET

from django.shortcuts import render
from django.views.decorators.http import require_GET

from .viewmodels import Book, SearchModel

# CONSTANTS of the module: the data, written in the code for the example
GENRES = ["novel", "poetry", "drama"]
BOOKS = [
    Book("Le Rouge et le Noir", "Stendhal", "novel"),
    Book("Les Fleurs du mal", "Baudelaire", "poetry"),
    Book("Madame Bovary", "Flaubert", "novel"),
    Book("Alcools", "Apollinaire", "poetry"),
    Book("Le Cid", "Corneille", "drama"),
]


# GET / and GET /search?keyword=...&genre=...: the form AND its results
@require_GET
def search(request):
    keyword = request.GET.get("keyword", "")
    genre = request.GET.get("genre", "")
    word = keyword.lower()
    # a list COMPREHENSION: the books that satisfy the condition
    results = [book for book in BOOKS
               if (word == "" or word in book.title.lower() or word in book.author.lower())
               and (genre == "" or book.genre == genre)]
    return render(request, "books/search.html", {
        "model": SearchModel(keyword, genre, GENRES, results),
    })
  • lines 9–16: two module constants: the list of genres and the list of books. In a real application, these would come from a database;
  • lines 20–21: a single view referenced by two routes ([config/urls.py]: [""] and ["search"], named [books_home] and [books_search]). On the first visit ([/]), there are no parameters, and all books are displayed;
  • lines 22–23: the form fields are read as parameters for URL (example 07), with an empty string as the default value;
  • lines 24–28: filtering, using a list comprehension with a condition ([if]): a book is selected if the keyword is empty or appears in its title or author (in lowercase on both sides: [lower()] correctly handles accented characters), and if the genre is empty or matches the book’s genre. A condition can span multiple lines, enclosed in parentheses;
  • lines 29–31: the template.

4.5.3. The template: templates/books/search.html

{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">Book search</h1>
  {# method="get": the fields are sent in the URL (?keyword=...&genre=...) #}
  <form method="get" action="{% url 'books_search' %}" class="row g-2 align-items-end mb-3">
    <div class="col-auto">
      <label class="form-label" for="keyword">Title or author</label>
      {# value: the field displays again what was typed (escaped by {{ }}) #}
      <input class="form-control" id="keyword" name="keyword" value="{{ model.keyword }}">
    </div>
    <div class="col-auto">
      <label class="form-label" for="genre">Genre</label>
      <select class="form-select" id="genre" name="genre">
        <option value="">(all)</option>
        {% for genre in model.genres %}
          {# the boolean attribute selected, written if the condition is true, omitted otherwise #}
          <option value="{{ genre }}"{% if genre == model.genre %} selected{% endif %}>{{ genre }}</option>
        {% endfor %}
      </select>
    </div>
    <div class="col-auto">
      <button class="btn btn-primary" type="submit">Search</button>
    </div>
  </form>
  <p>{{ model.results|length }} book(s) found.</p>
  <ul>
    {% for book in model.results %}
      <li><i>{{ book.title }}</i>, {{ book.author }} ({{ book.genre }})</li>
    {% endfor %}
  </ul>
{% endblock %}
  • line 6: the [<form>] tag. The [action] attribute is the URL to which the form is submitted; [method="get"] places the fields in the URL. The classes of [Bootstrap] arrange the fields on a single line;
  • line 8: a label. Its [for] attribute specifies the identifier ([id]) of the field it describes: clicking the label places the cursor in the field, and screen readers read the label along with the field;
  • line 10: an input field. Its attribute [name] is the name of the parameter sent to the server: this is the link between the form and the view ([request.GET.get("keyword", "")]). The attribute [value] is the displayed value: the previous entry, redisplayed (and escaped by [{{ }}], which is essential in an attribute: an entry containing a quotation mark ["] would otherwise close the attribute);
  • lines 14–20: a drop-down list. Each [<option>] has a value ([value]), sent to the server, and text, displayed. The first option, with an empty value, means “all genres”;
  • line 18: the option selected in the previous request is assigned the attribute [selected]. This is a Boolean attribute: its presence alone is sufficient; it has no value. The condition sets it if true, and sets nothing otherwise;
  • line 23: the submit button;
  • line 26: the number of results.

4.5.4. Execution

Image

Let’s enter [le] and select the type [novel]:

Image

The URL on the results page contains the form fields: [/search?keyword=le&genre=novel]. The response from the form:

curl "http://localhost:8000/search?keyword=le&genre=novel"
...
      <input class="form-control" id="keyword" name="keyword" value="le">
...
      <select class="form-select" id="genre" name="genre">
        <option value="">(all)</option>


          <option value="novel" selected>novel</option>


          <option value="poetry">poetry</option>


          <option value="drama">drama</option>

      </select>
...
  <p>1 book(s) found.</p>
  <ul>

      <li><i>Le Rouge et le Noir</i>, Stendhal (novel)</li>

  </ul>
...

“Madame Bovary” is not selected: “le” does not appear in either the title or the author’s name.

4.6. Example 18: Form fields

This example shows all the field types in a HTML form, which was sent this time by POST. It demonstrates how to redisplay each field type along with its current value, how the browser sends the values for each field, and how the server verifies that a received value is indeed one of the available options. Writing all of this by hand, as in Example 17, would be time-consuming and repetitive. The forms from [Django], which we used in the previous chapter to validate parameters, can also be displayed: each field has a widget—an object that creates its input field in HTML, along with the current value—and the form redisplays the input and any errors. This is the complete version of what the [Symfony] course delegated to its Form component, and what the [Flask] course delegated to [WTForms].

18-fields/
├── config/
│   ├── rendering.py
│   ├── settings.py
│   └── ...
├── form/
│   ├── __init__.py
│   ├── forms.py
│   ├── lists.py
│   └── views.py
├── static/
│   └── ...
├── templates/
│   ├── django/
│   │   └── forms/
│   │       └── widgets/
│   │           ├── input_option.html
│   │           └── multiple_input.html
│   ├── form/
│   │   └── form.html
│   ├── forms/
│   │   └── field.html
│   └── layout.html
└── manage.py

4.6.1. Lists: form/lists.py

1
2
3
4
5
6
7
8
9
# form/lists.py: the lists offered by the form. In a real
# application, they would come from a database

# each list: pairs (sent value, displayed text)
TITLES = [("Mr", "Mr"), ("Mme", "Mme"), ("Melle", "Melle")]
COUNTRIES = [("fr", "France"), ("be", "Belgium"), ("ch", "Switzerland"), ("ca", "Canada")]
HOBBIES = [("reading", "reading"), ("music", "music"), ("sport", "sport"), ("cinema", "cinema")]
LANGUAGES = [("French", "French"), ("English", "English"), ("Spanish", "Spanish"),
             ("German", "German")]

The form’s dropdown lists (titles, countries, hobbies, languages) are constants in a module: in a real application, they would come from a database. Each list consists of (value, text) pairs: the value is what is sent to the server, and the text is what is displayed (example 08). A pair is written in parentheses: it is a tuple, a sequence of values that cannot be modified.

4.6.2. The form: form/forms.py

# form/forms.py: the DESCRIPTION of the form: its fields, their type, their widget

from django import forms

from . import lists


# a parent class for our forms: it gives each input area the
# Bootstrap CSS class that suits it
class BootstrapForm(forms.Form):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # fields: the fields of the form, in a dictionary {name: field}
        for field in self.fields.values():
            widget = field.widget
            if isinstance(widget, (forms.RadioSelect, forms.CheckboxSelectMultiple)):
                css_class = "form-check-input"
            elif isinstance(widget, forms.Select):
                css_class = "form-select"
            else:
                css_class = "form-control"
            # attrs: the HTML attributes of the input area
            widget.attrs["class"] = css_class


# the fields of the form, in their display order. The FIELD (forms.XxxField)
# converts and validates the value; the WIDGET (forms.XxxInput...) draws the input area
class FieldsForm(BootstrapForm):
    # radio buttons: a list of choices, drawn by the RadioSelect widget;
    # error_messages: the message of the "required field" error
    title = forms.ChoiceField(choices=lists.TITLES, widget=forms.RadioSelect,
                              error_messages={"required": "The title is required."})
    # an input area; required=False: the field is not required
    name = forms.CharField(required=False)
    # a password: never displayed again in the page
    password = forms.CharField(label="Password", required=False, widget=forms.PasswordInput)
    # a date: an <input type="date"> field; the value is a Python date
    birth_date = forms.DateField(label="Date of birth", required=False,
                                 widget=forms.DateInput(attrs={"type": "date"}))
    # checkboxes: a list of MULTIPLE choices
    hobbies = forms.MultipleChoiceField(choices=lists.HOBBIES, required=False,
                                        widget=forms.CheckboxSelectMultiple)
    # drop-down list: the default widget of a ChoiceField
    country = forms.ChoiceField(choices=lists.COUNTRIES)
    # multiple-selection list, displayed on four lines
    languages = forms.MultipleChoiceField(label="Spoken languages", choices=lists.LANGUAGES,
                                          required=False,
                                          widget=forms.SelectMultiple(attrs={"size": 4}))
    comment = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 2}))
    # a hidden field
    token = forms.CharField(widget=forms.HiddenInput)
  • Lines 10–23: A parent class for our forms, which assigns each input field the appropriate class (CSS derived from [Bootstrap]). It redefines the constructor ([__init__]), which receives the same parameters as that of [forms.Form] ([*args], [**kwargs], example 08), and begins by calling it ([super().__init__(...)]). The form’s [fields] attribute is then a dictionary of its fields; each field has its own widget ([field.widget]), whose dictionary [attrs] contains the attributes HTML of the input field. [isinstance] accepts a tuple of classes: is the widget of one of these classes? Radio buttons and checkboxes take the class [form-check-input], lists take [form-select], and others take [form-control];
  • Lines 28–51: the fields, in the order they are displayed. Two objects must be distinguished: the field ([forms.XxxField]) converts and validates the value, as in the previous chapter; its widget ([forms.XxxInput], [forms.Select]...) draws the input field. Each field has a default widget, which is replaced by the parameter [widget]. By default, a field’s label is its name, formatted ([password] would become “Password,” [name] “Name”); the parameter [label] selects it;
  • Lines 31–32: A [ChoiceField] is a drop-down list. With the [RadioSelect] widget, it becomes a group of radio buttons (a single selection). The [error_messages] parameter (example 09) displays the “required field” error message;
  • line 34: a [CharField] (default widget: [TextInput], a [<input type="text">]), optional. A required field ([required=True], the default) generates the attribute HTML [required], which the browser checks (example 19);
  • line 36: a password (widget [PasswordInput], a [<input type="password">]), which is never returned to the page: its field is always empty when displayed;
  • lines 38–39: a date ([DateField]), whose value will be an object [date] of type [Python]. The [DateInput] widget receives the [type="date"] attribute: a [<input type="date">] field, for which the browser displays a calendar. Such a field exchanges its value with the server in the [yyyy-mm-dd] format, regardless of the format in which the browser displays it ([DateField] accepts this format);
  • lines 41–42: a [MultipleChoiceField] (example 10), drawn by the [CheckboxSelectMultiple] widget: checkboxes;
  • Line 44: a [ChoiceField] with its default widget, [Select]: a drop-down list;
  • Lines 46–48: a [MultipleChoiceField] with its default widget, [SelectMultiple]: a multi-select list, displayed across four lines (the HTML [size] attribute, specified by [attrs]);
  • line 49: a text box ([Textarea]), spanning two lines;
  • line 51: a hidden field ([HiddenInput]).

[Django] also provides [EmailField] (example 19), [IntegerField], [DecimalField], [URLField], [BooleanField] (a single checkbox), [FileField], [ImageField], [SlugField], [UUIDField], [JSONField]... and other widgets: [EmailInput], [NumberInput], [CheckboxInput], [ClearableFileInput], [SelectDateWidget] (three lists: day, month, year)...

4.6.3. Field layout: config/rendering.py and form templates

A [Django] form is automatically generated using templates: one template for the entire form, one for each complete field (label, input field, tooltip, error messages), and one for each type of widget. [Django] provides all these templates in the [django.forms] application, with a neutral layout. To achieve the layout of [Bootstrap], we replace a few of them. First, [Django] must search for the form templates, just like the others, in the folders defined by the [TEMPLATES] configuration: this is the role of a form renderer:

# config/rendering.py: the rendering engine of the forms

from django.forms.renderers import TemplatesSetting


# TemplatesSetting: a rendering engine that looks for its templates like the other
# templates of the project (TEMPLATES setting): a template of the project's templates folder
# can therefore replace Django's one
class BootstrapRenderer(TemplatesSetting):
    # the template of a complete field: label, input area, help, errors
    field_template_name = "forms/field.html"
  • line 9: [TemplatesSetting] is the form renderer that searches for its templates using the project’s template engine: a template from the project’s [templates] folder can therefore replace a template from [Django] with the same name. Our class inherits this behavior;
  • Line 11: The [field_template_name] attribute specifies the template for a complete field: ours, [forms/field.html].

The configuration:

# config/settings.py: the configuration of example 18

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-b13ls-fk@*cj%i9(=5e1aa_eg(j0&*2cs)oc=j+qo&(!v06l^y"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "en"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    # the templates of Django's forms (one template per kind of field)
    "django.forms",
    "form",
]

MIDDLEWARE = [
    # the anti-CSRF protection of ALL the POST requests (see further on)
    "django.middleware.csrf.CsrfViewMiddleware",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        # APP_DIRS: Django also looks for the templates in the templates folder of
        # each application, including django.forms
        "APP_DIRS": True,
    },
]

# the RENDERING ENGINE of the forms: ours, which draws the fields with the
# Bootstrap 5 classes (see config/rendering.py)
FORM_RENDERER = "config.rendering.BootstrapRenderer"

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
    BASE_DIR.parent / "common" / "static",
]
  • line 17: the application [django.forms], which provides the form templates from [Django], in its folder [templates];
  • Lines 21–24: the anti-CSRF protection middleware (see below);
  • line 32: [APP_DIRS: True]: [Django] also searches for templates in the [templates] folder of each declared application, including [django.forms]. The [DIRS] folders are checked first: our templates take precedence over those in [Django];
  • line 38: the [FORM_RENDERER] setting specifies our rendering engine.

The template for a complete field:

{# a complete field, with the Bootstrap classes; field: the field to draw #}
{% if field.use_fieldset %}
  {# a group of radio buttons or checkboxes: a fieldset and its legend #}
  <fieldset class="mb-3">
    <legend class="form-label fs-6 mb-1">{{ field.label }}</legend>
    {{ field }}
{% else %}
  <div class="mb-3">
    <label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
    {{ field }}
{% endif %}
    {% if field.help_text %}<div class="form-text" id="{{ field.auto_id }}_helptext">{{ field.help_text }}</div>{% endif %}
    {% if field.errors %}<div class="invalid-feedback d-block" id="{{ field.auto_id }}_error">{% for error in field.errors %}{{ error }}{% if not forloop.last %}<br>{% endif %}{% endfor %}</div>{% endif %}
{% if field.use_fieldset %}</fieldset>{% else %}</div>{% endif %}

The template receives the field to be rendered, [field] (a [BoundField] object: the field, linked to the form, and therefore to its value and errors):

  • Lines 2–6: A group of radio buttons or checkboxes ([field.use_fieldset] applies to these widgets) is surrounded by a [<fieldset>], whose caption ([<legend>]) is the group label: this is what screen readers expect;
  • lines 8–10: another field has a [<label>], linked to the input field by its identifier ([id_for_label]: [id_name], by default [id_] followed by the field name). [{{ field }}] defines the input field using its widget’s template;
  • line 12: the field’s help text (parameter [help_text] for a field), if it has one;
  • line 13: the field’s errors ([field.errors]), using the classes from [Bootstrap] ([invalid-feedback]: red text, [d-block]: always visible). Its identifier, [id_name_error], is the one specified by [Django] in the [aria-describedby] attribute of the input field, so that screen readers read the message along with the field;
  • Line 14: the closing tag for the tag opened in the first part.

Finally, two widget templates from [Django] are replaced so that the radio buttons and checkboxes are displayed in the same way as in [Bootstrap], side by side:

1
2
3
4
{# replaces Django's template: a group of radio buttons or checkboxes, side by side #}
<div id="{{ widget.attrs.id }}">{% for group, options, index in widget.optgroups %}{% for option in options %}
  <div class="form-check form-check-inline">{% include option.template_name with widget=option %}</div>{% endfor %}{% endfor %}
</div>
1
2
3
{# replaces Django's template: ONE radio button or ONE checkbox, followed by its label #}
{% include "django/forms/widgets/input.html" %}
<label class="form-check-label" for="{{ widget.attrs.id }}">{{ widget.label }}</label>

They have exactly the same names and paths as the templates in [Django] that they replace ([django/forms/widgets/...], found in the [django/forms/templates] folder of the library):

  • [multiple_input.html] draws the group: for each option ([widget.optgroups]: the options, grouped together; we have no subgroups), a [<div class="form-check form-check-inline">] that contains the option, drawn using its own template ([option.template_name]);
  • [input_option.html] renders an option: the input field ([<input type="radio">] or [<input type="checkbox">], using the template from [Django] and [input.html]), followed by its label.

All of this needs to be written only once: Examples 19, 21, and 28, as well as the case study, use these files. Ready-made libraries, such as [django-crispy-forms] and its package [crispy-bootstrap5], or [django-bootstrap5], do the same; we preferred to demonstrate the mechanism.

4.6.4. The view: form/views.py

# form/views.py: a form that uses all the kinds of fields

from django.shortcuts import render
from django.views.decorators.http import require_http_methods

from .forms import FieldsForm

# the initial values (one per field, a list for a multiple field)
INITIAL_VALUES = {"title": "Mme", "country": "fr", "languages": ["French"], "token": "A7X2"}


# GET: the form, with initial values; POST: the sent fields are
# read, validated, then displayed again by the same form
@require_http_methods(["GET", "POST"])
def form(request):
    if request.method == "GET":
        # initial: the values displayed as long as nothing has been sent
        return render(request, "form/form.html",
                      {"form": FieldsForm(initial=INITIAL_VALUES)})
    # a form BOUND to the sent data: it converts and validates them
    form = FieldsForm(request.POST)
    valid = form.is_valid()
    data = None
    if valid:
        # cleaned_data: the values converted by each field; a list (multiple
        # field) is written as text, its values separated by commas
        data = {key: ", ".join(value) if isinstance(value, list) else value
                for key, value in form.cleaned_data.items()}
    # what the browser actually sent, field by field; lists(): the
    # pairs (name, list of all the values of the field)
    received = "\n".join(f"{name} = [{', '.join(values)}]" for name, values in request.POST.lists())
    return render(request, "form/form.html", {
        "form": form,
        "received": received,
        "valid": valid,
        "data": data,
    })
  • line 9: the form’s initial values: a dictionary, one value per field (a list for a multi-value field);
  • line 14: a single view for both methods: GET displays the form, POST processes it;
  • lines 16–19: in GET, an unbound form (without data), with its initial values (parameter [initial]): it is not validated and does not display any errors;
  • lines 21–22: in POST, a form linked to the submitted fields ([request.POST]), then validated ([is_valid], previous chapter): each field converts its value (the date, the lists, etc.), verifies that a selection is among those that were offered, and applies its constraints;
  • lines 24–28: if the form is valid, its data is converted ([cleaned_data]) into a human-readable dictionary (using the [{key: value for ...}] syntax): a list is written as text, with its elements separated by commas ([", ".join(...)]);
  • Line 31: For this example, what the browser actually sent. The method [lists()] of a [QueryDict] (Example 10) iterates through the pairs (name, list of all values). The string [\n] is a line break;
  • lines 32–37: the linked form is passed to the template, which will display it, along with the user’s input and any errors.

4.6.5. The template: templates/form/form.html

{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">A complete form</h1>
  <form method="post" action="{% url 'form' %}" style="max-width: 40rem">
    {# the anti-CSRF token: a hidden field, which the CsrfViewMiddleware middleware checks #}
    {% csrf_token %}
    {# as_field_group: a complete field (label, input area, errors) #}
    {{ form.title.as_field_group }}
    <div class="row g-2">
      <div class="col">{{ form.name.as_field_group }}</div>
      <div class="col">{{ form.password.as_field_group }}</div>
      <div class="col">{{ form.birth_date.as_field_group }}</div>
    </div>
    {{ form.hobbies.as_field_group }}
    <div class="row g-2">
      <div class="col">{{ form.country.as_field_group }}</div>
      <div class="col">{{ form.languages.as_field_group }}</div>
    </div>
    {{ form.comment.as_field_group }}
    {# a hidden field: the input area alone, without label #}
    {{ form.token }}
    <button class="btn btn-primary" type="submit">Send</button>
  </form>
  {# received: present in the context if the form has been sent #}
  {% if received %}
    <h2 class="h5 mt-3">What the server received</h2>
    <pre class="bg-light p-2 border">{{ received }}</pre>
    <p>Valid form: {{ valid|yesno:"yes,no" }}</p>
    <ul>
      {# errors: a dictionary {field: list of errors}; items: its pairs #}
      {% for field, errors in form.errors.items %}
        {% for error in errors %}<li>{{ field }}: {{ error }}</li>{% endfor %}
      {% endfor %}
    </ul>
    {% if valid %}
      <p>The data of the form:</p>
      <ul>
        {% for key, value in data.items %}
          <li>{{ key }}: {{ value }}</li>
        {% endfor %}
      </ul>
    {% endif %}
  {% endif %}
{% endblock %}
  • Line 5: The [<form>] tag, entered manually: the [Django] form does not generate it;
  • line 7: the [{% csrf_token %}] tag creates a hidden field containing the anti-CSRF token (see below);
  • line 9: [{{ form.title }}] refers to the [title] field in the form (a [BoundField]); its method [as_field_group] renders it in full using the template [forms/field.html]: label, input field (with its current value), and error messages;
  • lines 10–20: the other fields, arranged according to the grid classes of [Bootstrap];
  • line 22: the hidden field, of which only the input field is rendered;
  • line 26: the variable [received] exists only after a submission; a missing variable is “false”;
  • lines 32–34: the errors. [form.errors] is a dictionary, which is iterated over using [items] (without parentheses, as in DTL): for each field, the list of its errors;
  • lines 39–41: a loop over a dictionary that iterates through both the keys and the values.

We could have written the entire form in a single line: [{{ form }}], which renders all the fields in order using the form template (rendering engine setting [form_template_name]). By processing the fields one by one, you can arrange them as you like.

4.6.6. CSRF Protection

A CSRF attack (Cross-Site Request Forgery) involves a malicious site tricking the victim’s browser into sending a form to our application. The browser includes our application’s cookies in the request—and thus the logged-in user’s session—so the request appears legitimate. The solution: every form in our application contains a random token that the malicious site cannot know.

[Django] applies this to the entire application via a middleware component, [CsrfViewMiddleware] (line 23 of the file [config/settings.py]). When a page is generated, the [{% csrf_token %}] tag writes to the hidden field [csrfmiddlewaretoken], and the middleware sends, along with the response, a cookie named [csrftoken], which contains a secret. For any request that modifies (POST, PUT, PATCH, DELETE), before rendering the view, the middleware verifies that the token sent (in the [csrfmiddlewaretoken] field, or in the [X-CSRFToken] header, for a request sent via [JavaScript]) matches the cookie’s secret. A malicious site cannot read our site’s cookie or view our pages: it cannot generate a valid token. If the verification fails, the view is not called: [Django] responds with a 403 error on its own. This is a significant difference from [Symfony], whose Form component verified the token for each form and let the action decide what to do next: With [Django], all POST requests are protected, and a view cannot forget to be protected. The [@csrf_exempt] decorator exempts a view from this verification (for a API called by other servers, for example).

4.6.7. Execution

Image

The initial values are visible: the “Mme” radio button, the country “France,” and the language “French.” The HTML generates (excerpts):

<form method="post" action="/form" style="max-width: 40rem">
    <input type="hidden" name="csrfmiddlewaretoken" value="HuaIyQ2pbEWYQCG1mTPuWEDSd9zjBvCQhGfposguhAGFVRfIl5gRe39M2HPOHKnm">
    <fieldset class="mb-3">
    <legend class="form-label fs-6 mb-1">Title</legend>
    <div id="id_title">
  <div class="form-check form-check-inline">
<input type="radio" name="title" value="Mr" class="form-check-input" required id="id_title_0">
<label class="form-check-label" for="id_title_0">Mr</label>
</div>
  <div class="form-check form-check-inline">
<input type="radio" name="title" value="Mme" class="form-check-input" required id="id_title_1" checked>
<label class="form-check-label" for="id_title_1">Mme</label>
</div>
...
<input type="checkbox" name="hobbies" value="reading" class="form-check-input" id="id_hobbies_0">
...
    <select name="languages" size="4" class="form-select" id="id_languages" multiple>
  <option value="French" selected>French</option>
  <option value="English">English</option>
...
    <input type="hidden" name="token" value="A7X2" class="form-control" id="id_token">
    <button class="btn btn-primary" type="submit">Send</button>
  </form>

[Django] has written everything: the identifiers ([id_title_1]), the labels associated with their fields, the field names (the checkboxes all have the name [hobbies]: the browser will send a repeated parameter, example 10), the attributes [checked] and [selected] for current values, the attribute [required] for required fields, and the classes of [Bootstrap]. The first hidden field contains the anti-CSRF token; it is different on every page (it is “masked” by a random number, so that a network eavesdropper cannot guess the secret by comparing multiple pages). Let’s fill out the form and submit it:

Image

The page shows what the server received: one parameter per field, with multiple values for the checkboxes and the dropdown list; the password was sent but is not displayed again in its field. The browser displayed the date according to its own settings, but sent it in the format [yyyy-mm-dd]; the template formatted it in the English style, [May 14, 1990], just like any date it displays. The hidden field [token] was sent back exactly as is: this is how information that the user cannot see is sent back and forth (the case study uses this for a record’s version number). However, a hidden field is not a protected field: the user can modify it using their browser’s developer tools, and the server must validate it just like any other field. The anti-CSRF token is also included among the received fields; it is not part of the form data.

Now let's submit a form using [curl], without a token, and with a country that wasn't listed:

curl -i -d "name=Martin&country=xx" http://localhost:8000/form
1
2
3
4
5
6
7
8
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8

<!DOCTYPE html>
<html lang="en">
...
  <title>403 Forbidden</title>
...

The request did not proceed beyond the middleware: the view was not called, and the server console indicates this:

Forbidden (CSRF cookie not set.): /form
[27/Sep/2026 19:30:21] "POST /form HTTP/1.1" 403 2908

In a browser, the error page for [Django] explains the reason for the rejection (in debug mode):

Image

For the token to be accepted, [curl] must behave like a browser: first request the form, retaining the [csrftoken] cookie (option [-c], which saves received cookies to a file), then resend the cookie (option [-b]) and the token read from the page:

curl -c cookies.txt http://localhost:8000/form
curl -b cookies.txt -d "csrfmiddlewaretoken=k4ZfzHvE...yBD&name=Martin&country=xx&token=A7X2" http://localhost:8000/form
...
    <pre class="bg-light p-2 border">csrfmiddlewaretoken = [k4ZfzHvER4iJYcaDFRMsYh0OizTJGRMw2AtHYeGuGelfF3DZahfrxLcwUbVn0yBD]
name = [Martin]
country = [xx]
token = [A7X2]</pre>
    <p>Valid form: no</p>
    <ul>


        <li>title: The title is required.</li>

        <li>country: Select a valid choice. xx is not one of the available choices.</li>

    </ul>
...

(the token is the one from the page received by the first request). This time, the view was called, and the form is invalid for two reasons: no title was sent, and the country [xx] is not among the options. This final check is performed by the [ChoiceField] field itself, without us having requested it: a malicious user could send anything, not just what the page suggested. Here, the view returns the invalid form with a 200 status code; the following example will choose a more appropriate status code.

4.7. Example 19: Form Validation

A registration form, in which each field has its own constraints. If an error occurs, the form is redisplayed with the user’s input and a message below each invalid field. We add a rule that applies to two fields: the password and its confirmation must match.

19-form-validation/
├── config/
│   ├── rendering.py
│   ├── settings.py
│   └── ...
├── registration/
│   ├── __init__.py
│   ├── forms.py
│   └── views.py
├── static/
│   └── ...
├── templates/
│   ├── forms/
│   │   └── field.html
│   ├── registration/
│   │   ├── registration.html
│   │   └── welcome.html
│   └── layout.html
└── manage.py

The files [config/settings.py], [config/rendering.py], and [templates/forms/field.html] are the same as those in Example 18.

4.7.1. The form: registration/forms.py

# registration/forms.py: the registration form, its fields and their constraints

from django import forms


# the parent class of example 18: the Bootstrap CSS classes
class BootstrapForm(forms.Form):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for field in self.fields.values():
            field.widget.attrs["class"] = "form-control"


class RegistrationForm(BootstrapForm):
    # max_length: a constraint checked by the server, which Django also writes into
    # the page (maxlength attribute), for the browser
    name = forms.CharField(max_length=20, error_messages={
        "required": "The name is required.",
        "max_length": "The name must not exceed 20 characters."})
    # EmailField: a valid address, with a full domain name (example.com, and not example)
    email = forms.EmailField(label="Email address", error_messages={
        "required": "The email address is required.",
        "invalid": "Invalid email address."})
    # IntegerField: an integer; an input that is not one produces the invalid error
    age = forms.IntegerField(label="Age", min_value=18, max_value=120, error_messages={
        "required": "The age is required.",
        "invalid": "An integer is expected.",
        "min_value": "The age must be between 18 and 120 years.",
        "max_value": "The age must be between 18 and 120 years."})
    password = forms.CharField(label="Password", min_length=6, widget=forms.PasswordInput,
                               error_messages={
                                   "required": "The password is required.",
                                   "min_length": "The password must have at least 6 characters."})
    confirmation = forms.CharField(label="Confirmation", widget=forms.PasswordInput,
                                   error_messages={"required": "The confirmation is required."})

    # clean: the validation of the WHOLE form, after that of each field. It is
    # here that two fields are compared
    def clean(self):
        data = super().clean()
        password = data.get("password")
        confirmation = data.get("confirmation")
        # the two fields passed their own validation, but differ
        if password and confirmation and password != confirmation:
            # add_error: an error, attached to the confirmation field
            self.add_error("confirmation", "The confirmation does not match the password.")
        return data
  • lines 7–11: the parent class from Example 18, simplified: all fields are input fields;
  • lines 17–19: the name is required and must not exceed 20 characters. Each error is assigned its own message based on its code (example 09). The parameter [max_length] has two effects: the field checks the length (on the server), and its widget sets the attribute HTML [maxlength], which limits the input in the browser ([min_length] does the same, with the attribute [minlength]);
  • lines 21–23: a [EmailField] verifies that an address is valid and requires a fully qualified domain name ([example.com], not [example]). Its widget, [EmailInput], generates a [<input type="email">], which the browser can validate;
  • lines 25–29: a [IntegerField] produces a [<input type="number">], with the attributes [min] and [max] derived from [min_value] and [max_value], and converts the input to an integer. An entry that is not an integer produces the error [invalid];
  • lines 30–35: the password (at least 6 characters) and its confirmation, two [CharField] fields using the [PasswordInput] widget. A parameter can span multiple lines;
  • lines 39–47: the method [clean] validates the entire form after validating each field (and its methods [clean_<field>], example 12). [super().clean()] returns [cleaned_data], which contains only the valid fields: [data.get("password")] equals [None] if the password has already been rejected (too short). The comparison is performed only if both fields are valid; if they differ, [add_error] attaches an error to the [confirmation] field ([add_error(None, "...")] would attach it to the form itself: a “general” error, which is returned by [non_field_errors()]). This is the way to validate a rule that applies to multiple fields.

4.7.2. The view: registration/views.py

# registration/views.py: a form validated by the server

from django.shortcuts import render
from django.views.decorators.http import require_http_methods

from .forms import RegistrationForm


# GET /register: the form; ?html5=true: we let the browser validate it
# before sending. POST /register: the sent form
@require_http_methods(["GET", "POST"])
def register(request):
    html5 = request.GET.get("html5") == "true"
    if request.method == "POST":
        form = RegistrationForm(request.POST)
        # is_valid: each field could be converted and respects its constraints, and
        # the clean method did not report any error
        if form.is_valid():
            return render(request, "registration/welcome.html",
                          {"registration": form.cleaned_data})
        # an invalid form, displayed again with the input and the errors, with the
        # 422 code (Unprocessable Content)
        return render(request, "registration/registration.html",
                      {"form": form, "html5": html5}, status=422)
    # the first display: an empty form
    return render(request, "registration/registration.html",
                  {"form": RegistrationForm(), "html5": html5})
  • Line 13: The parameter [?html5=true] enables browser validation (see below);
  • lines 15–20: a form linked to the submitted fields. If it is valid, the welcome page receives its data (the following example will show that it is better to redirect after a successful POST);
  • lines 23–24: otherwise, the form is redisplayed, showing the input and any errors, along with code 422 (Unprocessable Content: “the request is well-formed, but its content is invalid”);
  • lines 26–27: on the first display, an empty, unbound form.

The invalid form is therefore redisplayed by the POST view itself, which retains the input and errors: this is the standard approach used by [Django], as well as by [Symfony]. Other frameworks, such as [Laravel], instead respond by redirecting to the form page, while preserving the input and errors in the session for the next request.

4.7.3. Templates

{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">Registration</h1>
  {% if html5 %}
    <p>Browser validation <b>enabled</b> - <a href="{% url 'register' %}">disable it</a></p>
  {% else %}
    <p>Browser validation <b>disabled</b> - <a href="{% url 'register' %}?html5=true">enable it</a></p>
  {% endif %}
  {# novalidate: the browser ignores the required, min... attributes and sends the form as is #}
  <form method="post" {% if not html5 %}novalidate{% endif %}>
    {% csrf_token %}
    {# a loop over the form: its fields, each one with its label and its errors #}
    {% for field in form %}
      {{ field.as_field_group }}
    {% endfor %}
    <button class="btn btn-primary" type="submit">Register</button>
  </form>
{% endblock %}

{% block page_bottom %}
  {# errors: the errors of the form; empty before sending, or if everything is valid #}
  {% if form.errors %}
    <div class="error-area" role="alert">The form contains errors.</div>
  {% endif %}
{% endblock %}
  • lines 5–9: a link to enable or disable browser validation;
  • line 11: the [novalidate] attribute disables browser validation: the form is submitted as-is, and only the server validates it. This is what we want to observe first. The form does not have the [action] attribute: it is submitted to the URL of the current page;
  • lines 14–16: a loop over the form itself: it iterates through its fields, in the order they are declared, with each field rendered as complete;
  • lines 21–26: the [page_bottom] block, defined by the parent template (see below). [form.errors] is empty before submission, or if everything is valid. If the form contains errors, a general message appears in a field whose style is defined by [static/css/styles.css] (example 15). The [role="alert"] attribute flags this for screen readers.
<!doctype html>
{% load static %}
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Registration</title>
  <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
  <link rel="stylesheet" href="{% static 'css/styles.css' %}">
</head>
<body>
  <div class="container py-3" style="max-width: 34rem">
    <main>
      {% block content %}{% endblock %}
    </main>
    {# a second block, at the bottom of the page #}
    {% block page_bottom %}{% endblock %}
  </div>
</body>
</html>
  • Line 17: a second block, [page_bottom], empty by default: a template can have as many blocks as needed.
1
2
3
4
5
6
7
8
{% extends "layout.html" %}

{% block content %}
  {# registration: the validated data of the form (cleaned_data) #}
  <h1 class="h3">Welcome {{ registration.name }}</h1>
  <p>Your registration is saved: address <b>{{ registration.email }}</b>,
    age <b>{{ registration.age }}</b> years.</p>
{% endblock %}

[registration] is the dictionary of validated data: spaces have been removed from the name (see below), and the age is an integer.

4.7.4. Execution

Let’s submit a form full of errors: a name consisting of spaces, an address without a full domain, an age of 12, a password that’s too short, and a different confirmation:

Image

The input was retained (except for the passwords), and each error is flagged below its corresponding field. The name consisting of spaces is flagged as missing: a ***[CharField] removes the spaces surrounding each entry (parameter ***[strip]***, example 08), and redisplays the cleaned-up value. The confirmation, however, contains no errors, even though it differs from the password: since the password was rejected, the [clean]* method did not compare them. The URL is the form’s, [/register]: the page was displayed by the view in response to POST. With [curl], by providing the anti-CSRF token as in Example 18, you can also send something a browser would not send—a non-numeric age:

curl -i -b cookies.txt -d "csrfmiddlewaretoken=...&name=Martin&email=jean.martin@example.com&age=abc&password=secret123&confirmation=secret123" http://localhost:8000/register
1
2
3
4
5
6
HTTP/1.1 422 Unprocessable Content
...
    <label class="form-label" for="id_age">Age</label>
    <input type="number" name="age" value="abc" min="18" max="120" class="form-control" required aria-invalid="true" aria-describedby="id_age_error" id="id_age">
    <div class="invalid-feedback d-block" id="id_age_error">An integer is expected.</div>
...

We see the input redisplayed, the attributes HTML5 ([required], [min], [max]), the error message [invalid], and the attributes [aria-invalid] and [aria-describedby]—which [Django] automatically adds to a field with an error—and which link the field to its message for screen readers.

4.7.5. Browser validation

Let's remove the [novalidate] attribute by clicking the "Enable" link (parameter [?html5=true]). The browser then checks the attributes [required], [type="email"], [min], [max], [maxlength]... before submitting the form, and displays its own messages in a tooltip below the first field with an error (“Please fill in this field,” “The value must be greater than or equal to 18,” etc.): the form is not submitted as long as there is an error. [Django] generated these attributes automatically based on the fields: [required] for any required field, [maxlength] and [minlength] based on [max_length] and [min_length], [min] and [max] based on [min_value] and [max_value], [type="email"] and [type="number"] based on the widgets. They therefore always comply with the server’s constraints. Only the rule for the [clean] method (confirmation) is not handled by the browser.

This validation is convenient for the user: it’s immediate, with no back-and-forth with the server. But it does not replace the server’s validation: a user can disable it, and a program like [curl] ignores it completely. The rule is absolute: the server always validates; browser validation is merely an added convenience.

A correct entry, at last. The name, entered with spaces on either side, was cleaned up by the form:

Image

4.8. Example 20: The Post/Redirect/Get Pattern

When a POST view responds with a page, that page is the result of a POST. If the user reloads it (presses [F5]), the browser sends the POST, after a warning that few people read: the command is sent twice, and the message is posted twice. The Post/Redirect/Get pattern (PRG) avoids this problem: a successful POST view does not render a page; it responds with a redirect (example 04) to a page GET, which the browser requests immediately. Reloading this page simply re-executes the GET.

One problem remains: the GET page would like to display a message (“the task has been added”), but it is the POST view that knows this message, and it cannot pass a variable to a request that has not yet occurred. The solution: a flash message, posted by the POST view, which is retained until it is read, displayed, and then discarded. [Django] provides these through its [django.contrib.messages] application.

4.8.1. Configuration: config/settings.py

# config/settings.py: the configuration of example 20

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-a%lgt@s)1dxed4be^y25kc))1tt@7_%yytqt!xgh_v4=485(+k"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "en"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    # the FLASH MESSAGES: messages left by a request, displayed by the next one
    "django.contrib.messages",
    "tasks",
]

MIDDLEWARE = [
    "django.middleware.csrf.CsrfViewMiddleware",
    # the middleware of the flash messages: it reads them at the start of the request, and
    # stores them at the end of the request
    "django.contrib.messages.middleware.MessageMiddleware",
]

# where the flash messages are stored between two requests: in a COOKIE (see the
# course, "Where are flash messages stored?")
MESSAGE_STORAGE = "django.contrib.messages.storage.cookie.CookieStorage"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "OPTIONS": {
            "context_processors": [
                # the messages variable: the flash messages to display
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
    BASE_DIR.parent / "common" / "static",
]
  • line 17: the message application;
  • lines 21–26: its middleware, [MessageMiddleware], which reads pending messages at the beginning of each request and, at the end, stores those that have not been read (and any new ones);
  • line 30: the [MESSAGE_STORAGE] setting determines where messages are stored between requests: in this case, in a cookie (see below). By default, [Django] tries the cookie first, and then, if the message is too large, the session (see the chapter on scopes); our example does not have a session;
  • Lines 37–40: the message context processor: it makes the variable [messages] available to all templates.

4.8.2. The service: tasks/service.py

# tasks/service.py: the list of tasks, shared by all the users

import threading

# the server process stays in memory from one request to the next: a variable of the
# MODULE lives as long as it does. The list is therefore shared by all the
# requests of all the users (and lost when the server stops)
_tasks = ["Read chapter 3"]

# the server handles several requests AT THE SAME TIME, in several threads of execution
# (threads): a lock guarantees that only one at a time modifies the list
_lock = threading.Lock()


def list_tasks() -> list[str]:
    # a copy: the caller cannot modify the service's list
    with _lock:
        return list(_tasks)


def add(label: str) -> None:
    # with: the lock is taken here, and released on leaving the block, even after an exception
    with _lock:
        _tasks.append(label)

The task list is shared by all users and must persist from one request to the next. We saw this in Examples 01 and 06: in [Python], the server remains in memory from one request to the next. A simple module variable is therefore sufficient. Readers of the [Symfony] course will recall that, in [PHP], the list had to be stored in a cache or a file.

  • Line 8: the list, a module variable. It exists for as long as the server process runs: it is lost when the server shuts down or restarts (after a code change, during development). And if the production server runs multiple processes (which is standard practice: one per processor, for example), each will have its own list. A real-world application would therefore store the list in a database (Chapter 7);
  • Line 12: The development server processes multiple requests at the same time, each in its own thread. Two simultaneous updates could conflict. A lock ([threading.Lock]) ensures that only one thread at a time executes the code it protects;
  • lines 15–18: The function returns a copy of the list, created under the protection of the lock;
  • lines 21–24: The statement [with] acquires the lock, executes the block, and then releases the lock—in all cases, even if an exception interrupts the block. An object that can be used by [with] is called a context manager; files are another example ([with open(...) as f:] closes the file at the end of the block).

The service is a module, not a class: its functions are called by [service.list_tasks()] and [service.add(...)]. This is the simplest way, in [Python], to have a single object shared by the entire application (Example 06).

4.8.3. Views: tasks/views.py

# tasks/views.py: the Post / Redirect / Get pattern

from django.contrib import messages
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods, require_POST

from . import service


# GET /tasks: the list and the add form; POST /tasks: the addition, according to the
# Post / Redirect / Get pattern
@require_http_methods(["GET", "POST"])
def tasks(request):
    if request.method == "GET":
        return render(request, "tasks/tasks.html", {"tasks": service.list_tasks()})
    label = request.POST.get("label", "").strip()
    if label == "" or len(label) > 40:
        # invalid input: we display the page again, without a redirect
        return render(request, "tasks/tasks.html", {
            "tasks": service.list_tasks(),
            "label": label,
            "error": "The label must have 1 to 40 characters."})
    service.add(label)
    # a "flash" message: it will survive the redirect, for the NEXT request
    messages.success(request, f"The task [{label}] has been added.")
    # no page: a redirect to the GET page
    return redirect("tasks")


# POST /tasks/without-prg: the counter-example (a page in response to the POST)
@require_POST
def add_without_prg(request):
    service.add(request.POST.get("label", "").strip() or "(untitled)")
    return render(request, "tasks/tasks.html", {"tasks": service.list_tasks(), "without_prg": True})
  • lines 16–22: The input is validated manually to keep the example concise ([strip] removes the surrounding spaces). An invalid entry is redisplayed without redirection: there is nothing to protect against a duplicate submission, since nothing has been modified;
  • line 25: [messages.success] displays a “success” level flash message " (there are also [debug], [info], [warning], and [error]: the level allows messages to be displayed differently);
  • line 27: the redirect to the page GET, named [tasks] (example 04);
  • lines 31–34: the counterexample: a page in response to POST. The operator [or] returns its left operand if it is “true” (here, a non-empty string), and the right operand otherwise.

4.8.4. The templates

<!doctype html>
{% load static %}
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tasks</title>
  <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
  <link rel="stylesheet" href="{% static 'css/styles.css' %}">
</head>
<body>
  <div class="container py-3" style="max-width: 40rem">
    {# the flash messages: read once, they disappear #}
    {% for message in messages %}
      <div class="alert alert-success" role="status">{{ message }}</div>
    {% endfor %}
    <main>
      {% block content %}{% endblock %}
    </main>
  </div>
</body>
</html>
  • Lines 14–16: [messages] contains the pending flash messages. Browsing through them marks them as read: the middleware will remove them at the end of the request; a flash message is displayed only once. The [alert alert-success] class of [Bootstrap] displays the message in a green frame. Each message also has a level ([message.level]) and labels ([message.tags]: [success]...), which determine its presentation.
{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">My tasks</h1>
  <ol>
    {% for task in tasks %}
      <li>{{ task }}</li>
    {% endfor %}
  </ol>
  <form method="post" action="{% url 'tasks' %}" class="d-flex gap-2">
    {% csrf_token %}
    <input class="form-control" name="label" value="{{ label }}" placeholder="New task">
    <button class="btn btn-primary text-nowrap" type="submit">Add</button>
    {# formaction: this button sends the SAME form to another address #}
    <button class="btn btn-outline-secondary text-nowrap" type="submit" formaction="{% url 'tasks_without_prg' %}">Add without PRG</button>
  </form>
  {% if error %}
    <div class="error-area" role="alert">{{ error }}</div>
  {% endif %}
  {% if without_prg %}
    <p class="text-danger mt-2">Page displayed in response to a POST: press F5 and the task will be added a second time.</p>
  {% endif %}
{% endblock %}
  • line 11: the anti-CSRF token: the [CsrfViewMiddleware] middleware is active (line 22 of the [config/settings.py] file);
  • line 12: the variable [label] only exists after an invalid input: a missing variable is displayed as an empty string;
  • line 15: the [formaction] attribute of a button submits the form to an address different from that of its [action] attribute: a single form can thus trigger two different views;
  • Lines 17–22: the error message and the warning from the counterexample.

4.8.5. Where are flash messages stored?

Between the POST request and the subsequent GET request, the message must be stored somewhere. We chose a cookie. Let’s examine it using [curl], by saving the cookies to a file (options [-c] and [-b], Example 18), after requesting the page to obtain the anti-CSRF token:

curl -i -b cookies.txt -c cookies.txt -d "csrfmiddlewaretoken=...&label=Do%20the%20exercises" http://localhost:8000/tasks
1
2
3
4
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /tasks
Set-Cookie: messages=W1siX19qc29uX21lc3NhZ2UiLDAsMjUsIlRoZSB0YXNrIFtEbyB0aGUgZXhlcmNpc2VzXSBoYXMgYmVlbiBhZGRlZC4iLCIiXV0:1xAsjI:xIWL3aUp2CbHdwZRyfv13cVIN1QG9baN2Ss020Pzyl0; HttpOnly; Path=/; SameSite=Lax

The response to POST is a redirect (status code 302, header [Location]), accompanied by the cookie [messages]. This cookie is [HttpOnly] (the [JavaScript] on the page cannot read it) and [SameSite=Lax] (the browser does not send it with a POST request originating from another site). Its value consists of three parts, separated by colons: the data, a date (encoded in base 62), and a signature. The data is the list of messages, in JSON, base-64-encoded (it is not compressed here: [Django] compresses the data only when that makes them shorter, and a compressed value begins with a period). When decoded, they yield:

[["__json_message",0,25,"The task [Do the exercises] has been added.",""]]

It shows the message level (25: success) and its text. The cookie’s content is therefore not secret: anyone can decode it. But it is signed: the signature is calculated based on the data and the application’s secret key ([SECRET_KEY], example 01). If the browser sends back a modified cookie, the signature no longer matches, and [Django] ignores it: a user cannot fabricate fake messages. Let’s follow the redirect by sending back the cookie:

curl -i -b cookies.txt -c cookies.txt http://localhost:8000/tasks
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Vary: Cookie
Set-Cookie: messages=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; SameSite=Lax
...
      <div class="alert alert-success" role="status">The task [Do the exercises] has been added.</div>
...
      <li>Read chapter 3</li>

      <li>Do the exercises</li>
...

The message is displayed; the middleware, seeing that it has been read, deletes the cookie (an empty cookie that expired in 1970). A new request, GET, would no longer display the message. The [Vary: Cookie] header alerts caches that the response depends on the request’s cookies. The task list, meanwhile, is stored in the server’s memory.

4.8.6. Execution

Adding a task: the browser followed the redirect; the URL header corresponds to the GET page, which can be safely reloaded.

Image

An invalid entry, redisplayed with its error message:

Image

The counterexample: the page is the response to POST, and its URL is [/tasks/without-prg]. If you click on [F5], the browser asks if you want to resubmit the form; if you agree, the task is added a second time.

Image

A principle of the HTTP protocol: a GET request does not change anything. Browsers, search engines, and tools that preload pages freely follow links without asking the user for permission. A “delete” link ([<a href="/contacts/2/delete">]) would therefore be dangerous: a bot crawling the site would delete everything. In a web application:

  • viewing is done via a link ([<a href>]), which sends a GET request;
  • editing (save, duplicate, delete) is done via a button placed in a form, protected by an anti-CSRF token.

This example manages a contact list and displays a confirmation window before each deletion.

21-links-buttons/
├── config/
│   ├── middleware.py
│   ├── rendering.py
│   ├── settings.py
│   └── ...
├── contacts/
│   ├── __init__.py
│   ├── forms.py
│   ├── service.py
│   ├── viewmodels.py
│   └── views.py
├── static/
│   ├── css/
│   │   └── styles.css
│   └── js/
│       └── app.js
├── templates/
│   ├── contacts/
│   │   ├── details.html
│   │   └── list.html
│   ├── forms/
│   │   └── field.html
│   ├── fragments/
│   │   └── confirmation.html
│   └── layout.html
└── manage.py

4.9.1. Method simulation: config/middleware.py

A HTML form can only send GET and POST requests. However, the HTTP protocol supports other methods: DELETE for deletion, and PUT and PATCH for updates. Many frameworks work around this limitation: a POST form that contains a hidden field [_method], with a value of [DELETE] (or [PUT], [PATCH]), is treated as a request for that method. This is known as method spoofing. [Symfony] offered this as an option; [Django] does not. Let’s implement it as middleware:

# config/middleware.py: the HTTP method of a form, replaced by its _method field

# an HTML form can only send GET and POST. A POST form that contains a
# hidden _method field (DELETE, PUT, PATCH) is handled as a request of that method
class MethodOverride:
    METHODS = {"DELETE", "PUT", "PATCH"}

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        return self.get_response(request)

    # process_view: Django calls it AFTER the routing, just before the view (and after the
    # process_view of the middlewares that precede this one, including the one that checks the
    # anti-CSRF token of a POST). Returning None: the request continues on its way
    def process_view(self, request, view_func, view_args, view_kwargs):
        if request.method == "POST":
            method = request.POST.get("_method", "").upper()
            if method in self.METHODS:
                request.method = method
        return None
  • line 6: a class attribute: the set ([set], enclosed in curly braces) of accepted methods;
  • lines 11–12: [__call__] passes the request on without doing anything;
  • lines 17–22: the method [process_view]: [Django] calls it after routing, just before calling the view, with the selected view and its parameters (the chapter on the lifecycle will cover this in detail). If the request is a POST whose [_method] field points to one of the accepted methods, the middleware replaces the method in the request. Returning [None] allows the request to continue on its path.

Why [process_view], and not [__call__]? Because of anti-CSRF protection. The [CsrfViewMiddleware] middleware performs its validation within its own method, [process_view], which is called before ours, since it precedes it in the [MIDDLEWARE] configuration. The token is therefore validated on the POST request, before we transform it into DELETE. This is what the comment in the [config/settings.py] file states:

# config/settings.py: the configuration of example 21

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-0c(8)1yo+s15vc6372!f@+0f@jpe2n+3)g34z*%s=h@ikzos9a"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "en"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    "django.contrib.messages",
    "django.forms",
    "contacts",
]

# the ORDER matters: the HTTP method is replaced (MethodOverride) only AFTER the
# check of the anti-CSRF token (see config/middleware.py)
MIDDLEWARE = [
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "config.middleware.MethodOverride",
]

MESSAGE_STORAGE = "django.contrib.messages.storage.cookie.CookieStorage"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

FORM_RENDERER = "config.rendering.BootstrapRenderer"

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
    BASE_DIR.parent / "common" / "static",
]
  • lines 23–27: three middleware components: anti-CSRF protection, messages (Example 20), and method simulation, in that order;
  • line 44: the form rendering engine from Example 18.

4.9.2. The service: contacts/service.py

# contacts/service.py: the contacts, shared by everyone, in memory (see example 20)

import threading

from .viewmodels import Contact

_contacts = [
    Contact(1, "Marie Durand", "01 23 45 67 89"),
    Contact(2, "Paul Martin", "06 12 34 56 78"),
    Contact(3, "Philippe Jandot", "02 98 76 54 32"),
]
_last_id = 3
_lock = threading.Lock()


def list_contacts() -> list[Contact]:
    with _lock:
        return list(_contacts)


# the contact, or None if it does not exist
def read(id: int) -> Contact | None:
    with _lock:
        return next((c for c in _contacts if c.id == id), None)


def update(id: int, name: str, phone: str) -> Contact | None:
    with _lock:
        # enumerate: the pairs (position, element) of the list
        for i, contact in enumerate(_contacts):
            if contact.id == id:
                # a contact is immutable (frozen): we build a new one, which
                # replaces the old one in the list
                _contacts[i] = Contact(id, name, phone)
                return _contacts[i]
        return None


def add(name: str, phone: str) -> Contact:
    # global: the function modifies the module's _last_id variable (without this word,
    # the assignment would create a local variable)
    global _last_id
    with _lock:
        _last_id += 1
        contact = Contact(_last_id, name, phone)
        _contacts.append(contact)
        return contact


def delete(id: int) -> Contact | None:
    with _lock:
        for i, contact in enumerate(_contacts):
            if contact.id == id:
                # pop removes element i from the list, and returns it
                return _contacts.pop(i)
        return None

Contacts are stored in memory, just like the tasks in Example 20. Each contact is an immutable object [Contact] (an ID, a name, a phone number, file [contacts/viewmodels.py]). A few points about [Python]:

  • line 12: the last assigned identifier;
  • lines 27–36: a contact is immutable; we create a new one, which replaces the old one in the list. [enumerate] iterates through a list in pairs (position, element);
  • lines 39–47: a function that assigns a value to a module variable must declare it using [global] (line 42): without this keyword, the assignment [_last_id += 1] would create a local variable for the function (and would fail here, since it reads its value before assigning it). Reading a module variable, or modifying the list it refers to ([_contacts.append(...)]), does not require anything of the sort;
  • line 55: [pop(i)] removes the element at position [i] from the list and returns it;
  • The functions return [None] for a nonexistent contact: it is up to the view to decide on the response.

4.9.3. The record form: contacts/forms.py

1
2
3
4
5
6
7
8
# contacts/forms.py: the form of the details page of a contact

from django import forms


class ContactForm(forms.Form):
    name = forms.CharField(widget=forms.TextInput(attrs={"class": "form-control"}))
    phone = forms.CharField(label="Phone", widget=forms.TextInput(attrs={"class": "form-control"}))

Both fields are required and have the class "[form-control]" from "[Bootstrap]," which is assigned directly to their widget. The buttons are not part of a "[Django]" form: they are hard-coded in the template.

4.9.4. Views: contacts/views.py

# contacts/views.py: links (GET) and buttons (POST, DELETE)

from django.contrib import messages
from django.http import Http404
from django.shortcuts import redirect, render
from django.views.decorators.http import require_GET, require_http_methods

from . import service
from .forms import ContactForm


# the contact, or a 404 response if it does not exist
def contact_or_404(contact):
    if contact is None:
        raise Http404("unknown contact")
    return contact


# a LINK (<a href>): a GET request, which modifies nothing
@require_GET
def list_contacts(request):
    return render(request, "contacts/list.html", {"contacts": service.list_contacts()})


# GET /contacts/2: the details page; POST /contacts/2: one of its two buttons;
# DELETE /contacts/2: the deletion (a POST and its _method field)
@require_http_methods(["GET", "POST", "DELETE"])
def contact(request, id):
    if request.method == "DELETE":
        return delete(request, id)
    contact = contact_or_404(service.read(id))
    if request.method == "GET":
        form = ContactForm(initial={"name": contact.name, "phone": contact.phone})
        return render(request, "contacts/details.html", {"contact": contact, "form": form})
    form = ContactForm(request.POST)
    if not form.is_valid():
        return render(request, "contacts/details.html", {"contact": contact, "form": form})
    name = form.cleaned_data["name"]
    phone = form.cleaned_data["phone"]
    # the clicked button is the only one to send its name: "duplicate" in request.POST
    # is true if it is the one that sent the form
    if "duplicate" in request.POST:
        copy = service.add(f"{name} (copy)", phone)
        messages.success(request, f"The contact [{copy.name}] has been created.")
    else:
        service.update(id, name, phone)
        messages.success(request, f"The contact [{name}] has been edited.")
    return redirect("contacts")


# the deletion; the anti-CSRF token has already been checked by CsrfViewMiddleware
def delete(request, id):
    contact = contact_or_404(service.delete(id))
    messages.success(request, f"The contact [{contact.name}] has been deleted.")
    return redirect("contacts")
  • lines 13–16: a utility function—a nonexistent contact returns a 404 response to terminate the view;
  • lines 20–22: the GET view of the list, which does not modify anything;
  • lines 27–48: a contact’s record. In GET, it displays the form, populated with the contact’s information; in POST, it processes the form; in DELETE, it deletes the contact. The same URL and [/contacts/2] are thus used to view, save, and delete. The decorator [require_http_methods] is checked when the view is called, i.e., after [process_view]: it does indeed see the method DELETE;
  • lines 35–37: an invalid form is redisplayed;
  • lines 42–47: A form can have multiple submit buttons. When the form is submitted, the browser sends, in addition to the fields, the name (attribute [name]) of the clicked button, and only that button’s name. [in] checks for the presence of a key in a dictionary: ["duplicate" in request.POST] is true if the form was submitted via the [duplicate] button. Depending on the button, the view either duplicates or saves the contact;
  • line 48: in both cases, the PRG flow (example 20), with a flash message;
  • lines 52–55: deletion. It does not need to verify the anti-CSRF token: [CsrfViewMiddleware] did so beforehand. A missing or invalid token would have stopped the request with a 403 error.

4.9.5. The templates

{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">Contacts</h1>
  <table class="table align-middle">
    <tbody>
      {% for contact in contacts %}
        <tr>
          <td>{{ contact.name }}</td>
          <td>{{ contact.phone }}</td>
          <td class="text-end">
            {# view: a simple link; url puts the id in the path: /contacts/2 #}
            <a class="btn btn-sm btn-outline-primary" href="{% url 'contacts_details' contact.id %}">View</a>
            {# delete: a form, whose submission will be confirmed #}
            <form method="post" class="d-inline" action="{% url 'contacts_details' contact.id %}"
                  data-confirmation="Delete the contact {{ contact.name }}?">
              {% csrf_token %}
              {# the DELETE method, which an HTML form cannot send #}
              <input type="hidden" name="_method" value="DELETE">
              <button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
            </form>
          </td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock %}
  • line 13: a link, presented as a button by the classes of [Bootstrap] ([btn]...): the appearance has nothing to do with the HTTP method. [{% url 'contacts_details' contact.id %}] places the ID in the path: [/contacts/2];
  • lines 15–21: one form for each “Delete” button, with no fields other than the token and the method. The [d-inline] class displays it on the same line as the link. The [data-confirmation] attribute contains the confirmation message: the [data-...] attributes are unassigned, intended for the [JavaScript] on the page;
  • line 17: the anti-CSRF token;
  • line 19: the hidden field [_method], which will turn this POST into a DELETE request.
{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">Contact no. {{ contact.id }}</h1>
  <form method="post">
    {% csrf_token %}
    {{ form.name.as_field_group }}
    {{ form.phone.as_field_group }}
    {# two submit buttons for a same form; name: the name of the button, sent if it is clicked #}
    <div class="d-flex gap-2">
      <button class="btn btn-primary" type="submit" name="save">Save</button>
      <button class="btn btn-outline-primary" type="submit" name="duplicate">Duplicate</button>
      <a class="btn btn-link" href="{% url 'contacts' %}">Back to the list</a>
    </div>
  </form>
{% endblock %}
  • lines 11–12: the two form submission buttons, each with its own name;
  • line 13: the link to return to the list.

4.9.6. The confirmation window

The browser can display a confirmation box (function [JavaScript] [confirm]), but it is not very attractive, and its appearance depends on the browser. Here we use a modal window from [Bootstrap], displayed by a few lines of [JavaScript]. The window is a fragment, included in the parent template, and is therefore present—though hidden—on all pages:

{# a single window for the whole page, hidden (d-none) at first #}
<div id="confirmation-modal" class="d-none">
  <div class="modal-backdrop fade show"></div>
  <div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title">Confirmation</h5>
        </div>
        <div class="modal-body">
          <p class="mb-0" id="confirmation-modal-message"></p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-action="no">No, go back</button>
          <button type="button" class="btn btn-danger" data-action="yes">Yes, confirm</button>
        </div>
      </div>
    </div>
  </div>
</div>
  • line 2: the [d-none] class from [Bootstrap] hides the element;
  • lines 3–4: the gray background, followed by the window, are drawn by the classes of [Bootstrap];
  • line 11: the message's position;
  • lines 14–15: the two buttons, of type [button] (not [submit]: they do not submit any form), distinguished by their [data-action] attribute.
<!doctype html>
{% load static %}
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Contacts</title>
  <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
  <link rel="stylesheet" href="{% static 'css/styles.css' %}">
</head>
<body>
  <div class="container py-3" style="max-width: 40rem">
    {% for message in messages %}
      <div class="alert alert-success" role="status">{{ message }}</div>
    {% endfor %}
    <main>
      {% block content %}{% endblock %}
    </main>
  </div>
  {# the confirmation window, present in all the pages #}
  {% include "fragments/confirmation.html" %}
  {# the script, at the end of the page: the elements it looks for already exist #}
  <script src="{% static 'js/app.js' %}"></script>
</body>
</html>
  • Lines 12–14: Flash messages (example 20);
  • line 20: the window, included on all pages;
  • line 22: the script, placed at the bottom of the page: when it runs, the elements it searches for already exist. It is a static file, stored in the [static/js] folder.

The [static/js/app.js] script:

// confirmation of the forms that carry the data-confirmation="..." attribute
// Without JavaScript, these forms are simply sent without confirmation.
(() => {
  const modal = document.getElementById('confirmation-modal');
  const message = document.getElementById('confirmation-modal-message');
  let pending = null; // the form to send if the user confirms

  // 1. we intercept the submission of the forms to confirm
  document.querySelectorAll('form[data-confirmation]').forEach((form) => {
    form.addEventListener('submit', (event) => {
      event.preventDefault(); // the submission is suspended
      pending = form;
      message.textContent = form.dataset.confirmation;
      modal.classList.remove('d-none'); // the window appears
    });
  });

  // 2. the two buttons of the window
  modal.addEventListener('click', (event) => {
    const action = event.target.dataset.action;
    if (action === 'yes') {
      pending.submit(); // submission, WITHOUT firing the 'submit' event again
    } else if (action === 'no') {
      modal.classList.add('d-none');
      pending = null;
    }
  });
})();
  • line 3: the code is enclosed in an anonymous function that is executed immediately; its variables do not interfere with those of the other scripts on the page;
  • lines 4–6: the window, the message location, and the form awaiting confirmation;
  • Lines 9–16: For each form that has the [data-confirmation] attribute, the [submit] event—triggered when the form is submitted—is intercepted. [preventDefault()] suspends the submission; the form is put on hold, and its message ([dataset.confirmation], the value of the [data-confirmation] attribute) is displayed in the window that appears;
  • lines 19–27: a single handler for clicks in the window: [event.target] is the element clicked; its attribute [data-action] specifies which button it is. “Yes” submits the pending form using the [submit()] method, which—unlike clicking a button—does not trigger the [submit] event (otherwise, the window would reappear); “No” hides the window.

Without [JavaScript], the form is simply submitted without confirmation: the page remains usable. This is progressive enhancement: [JavaScript] adds convenience, but the application does not depend on it.

4.9.7. Execution

The contact list:

Image

Clicking “Delete” displays the confirmation window:

Image

After confirmation, the (simulated) request DELETE, the redirection, and the pop-up message:

Image

A contact’s profile, with its two send buttons:

Image

A request without an anti-CSRF token is rejected, and a nonexistent contact returns a 404 error:

curl -i -X POST -d "_method=DELETE" http://localhost:8000/contacts/3
curl -i http://localhost:8000/contacts/9
1
2
3
4
5
6
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8
...
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
...

The error pages are still the detailed ones from the debug mode of [Django]. The chapter on the request lifecycle will show how to replace them with custom pages.