Beetje Coderen

Les 3 · Module 1 · Flask-basis

Jinja-templates en overerving

Haal de HTML uit je Python-code: render Jinja-templates met variabelen, lussen en filters, en geef elke pagina dezelfde basislayout met overerving.

⏱ 75–90 minuten leestijd + oefenen

Illustratie: een documentsjabloon en een browservenster, verbonden door een stippellijn, zoals een template dat een pagina wordt

🎯 Na deze les kun je

  • een template renderen met render_template en er variabelen aan meegeven
  • in Jinja lussen, voorwaarden en filters gebruiken
  • een basistemplate maken en die met extends en block uitbreiden
  • uitleggen waarom Jinja automatisch escapet en wanneer dat misgaat

Na les 2 staat je HTML als f-strings in app.py. Dat werkt voor één regel, maar een bon met een tabel, een formulier en een menu wordt zo snel onleesbaar. Je ziet niet meer waar Python ophoudt en HTML begint, en je moet zelf aan escape() denken. Jinja is de template-taal van Flask: je schrijft gewone HTML-bestanden met een paar gaten erin, en Flask vult die gaten met jouw gegevens.

In deze les verhuist alle HTML van de kassa naar templates. Aan het eind heeft elke pagina dezelfde kop en hetzelfde menu, en staat dat maar op één plek.

Waar we gebleven waren

Je hebt kassa-web/app.py met de routes /, /totaal/..., /fooi/..., /kassa/toevoegen, /bon en /kassa/leegmaken. De bon is een lijst bon met tuples (product, prijs). Start de server weer:

cd kassa-web && source .venv/bin/activate
flask --app app run --debug

De map templates

Flask zoekt templates standaard in een map templates naast app.py:

kassa-web/
├── app.py
└── templates/
    └── bon.html

Maak templates/bon.html:

<!doctype html>
<html lang="nl">
<head><meta charset="utf-8"><title>Bon</title></head>
<body>
  <h1>Bon</h1>
  <ul>
    {% for product, prijs in regels %}
      <li>{{ product }}: {{ "%.2f"|format(prijs) }} euro</li>
    {% else %}
      <li>De bon is nog leeg.</li>
    {% endfor %}
  </ul>
  <p>Totaal: {{ "%.2f"|format(totaal) }} euro</p>
</body>
</html>

En vervang de route in app.py:

from flask import render_template

@app.route("/bon")
def bon_tonen():
    totaal = sum((prijs for _, prijs in bon), Decimal("0"))
    return render_template("bon.html", regels=bon, totaal=totaal)

render_template leest het bestand, vult het met de keyword arguments die je meegeeft en geeft een string terug. Die string wordt de body van het antwoord.

De drie soorten haakjes

Syntax Betekenis Voorbeeld
{{ … }} expressie: waarde invullen {{ product }}
{% … %} statement: lus, voorwaarde, blok {% for x in lijst %} … {% endfor %}
{# … #} commentaar, komt niet in de HTML {# TODO: korting #}

Een for in Jinja kent een {% else %}: dat deel verschijnt als de lijst leeg is. Binnen een lus heb je loop.index (vanaf 1), loop.first en loop.last.

Voorspel eerst: wat toont dit stukje als regels twee items heeft?

{% for product, prijs in regels %}
  {{ loop.index }}/{{ loop.length }} {{ product|upper }}{% if not loop.last %},{% endif %}
{% endfor %}
Uitkomst

Bij [("koffie", 2.80), ("thee", 2.50)] krijg je 1/2 KOFFIE, 2/2 THEE (met wat witruimte ertussen). loop.length is het aantal items, |upper is een filter en na het laatste item komt geen komma.

Filters

Een filter bewerkt een waarde met |. Je kunt ze achter elkaar zetten.

Filter Doet Voorbeeld → resultaat
upper, lower, title hoofdletters "thee"|title → Thee
length lengte regels|length → 2
default("…") waarde als iets ontbreekt naam|default("gast")
format printf-opmaak "%.2f"|format(2.8) → 2.80
join(", ") lijst samenvoegen ["a","b"]|join(", ")
truncate(20) inkorten lange omschrijving afkappen

Je kunt ook een eigen filter maken. Een bedrag in Nederlandse notatie is handig voor de kassa:

@app.template_filter("euro")
def euro(bedrag):
    return f"€ {bedrag:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")

Nu schrijf je in elke template {{ totaal|euro }} en krijg je € 1.234,50.

Automatisch escapen

Zet maar eens via het formulier het product <b>gratis</b> op de bon. Je ziet de tekens <b> letterlijk: Jinja zet < om naar &lt; in elke {{ … }}. Je hoeft escape() dus niet meer zelf aan te roepen. Dat werkt alleen voor bestanden die eindigen op .html, .htm, .xml of .xhtml.

Met {{ tekst|safe }} zet je escapen uit. Gebruik dat nooit voor iets wat een gebruiker heeft ingevuld. Eén |safe op een productnaam en je hebt weer het XSS-lek uit les 2.

Overerving: één basislayout

Elke pagina heeft dezelfde <head>, kop en menu. Kopiëren is foutgevoelig. Met template-overerving (template inheritance) schrijf je die één keer in templates/base.html:

<!doctype html>
<html lang="nl">
<head>
  <meta charset="utf-8">
  <title>{% block titel %}Kassa{% endblock %} · kassa-web</title>
</head>
<body>
  <nav>
    <a href="{{ url_for('home') }}">Start</a>
    <a href="{{ url_for('toevoegen') }}">Toevoegen</a>
    <a href="{{ url_for('bon_tonen') }}">Bon</a>
  </nav>
  <main>
    {% block inhoud %}{% endblock %}
  </main>
</body>
</html>

Een pagina erft daarvan en vult alleen de blokken in. templates/bon.html wordt:

{% extends "base.html" %}
{% block titel %}Bon{% endblock %}
{% block inhoud %}
  <h1>Bon</h1>
  <table>
    {% for product, prijs in regels %}
      <tr><td>{{ product }}</td><td>{{ prijs|euro }}</td></tr>
    {% else %}
      <tr><td colspan="2">De bon is nog leeg.</td></tr>
    {% endfor %}
  </table>
  <p><strong>Totaal: {{ totaal|euro }}</strong></p>
  <form method="post" action="{{ url_for('leegmaken') }}"><button>Bon leegmaken</button></form>
{% endblock %}

url_for werkt ook in templates. {{ super() }} in een blok neemt de inhoud van het ouderblok over, bijvoorbeeld {% block titel %}Bon – {{ super() }}{% endblock %}.

Stukjes hergebruiken met include en macro's

  • {% include "_menu.html" %} plakt een ander template op die plek. Een underscore voor de naam is een gewoonte voor "stukje, geen hele pagina".
  • Een macro is een functie in Jinja, handig voor formuliervelden:
{# templates/_velden.html #}
{% macro veld(naam, label, waarde="") %}
  <label>{{ label }} <input name="{{ naam }}" value="{{ waarde }}"></label>
{% endmacro %}
{% from "_velden.html" import veld %}
{{ veld("product", "Product") }}
{{ veld("prijs", "Prijs", "0,00") }}

Typische fouten

  • jinja2.exceptions.TemplateNotFound: bon.html: de map heet niet precies templates, of hij staat niet naast app.py.
  • UndefinedError: 'totaal' is undefined in een filter: je bent vergeten totaal=… mee te geven aan render_template. Een losse {{ onbekend }} geeft gewoon niets, pas een bewerking erop faalt.
  • TemplateSyntaxError: Unexpected end of template: een {% endfor %}, {% endif %} of {% endblock %} ontbreekt.

Oefenen

  1. Maak templates/home.html die erft van base.html en op de startpagina het aantal regels op de bon toont ("Er staan 3 producten op de bon"). Gebruik het filter length.
  2. Zet het formulier van /kassa/toevoegen in templates/toevoegen.html. Toon de foutmelding uit les 2 (oefening 4) alleen als er een is, met {% if fout %}.
  3. Markeer in de bontabel elke regel boven de € 5,00 met class="duur". Gebruik {% if %} binnen het class-attribuut.
  4. Maak een macro bedragveld voor een prijsveld met inputmode="decimal" en gebruik die in het formulier.
Modelantwoord oefening 1
@app.route("/")
def home():
    return render_template("home.html", aantal=len(bon))
{% extends "base.html" %}
{% block inhoud %}
  <h1>Kassa</h1>
  <p>Er {{ "staat" if aantal == 1 else "staan" }} {{ aantal }} product{{ "" if aantal == 1 else "en" }} op de bon.</p>
{% endblock %}

Je kunt ook regels=bon meegeven en {{ regels|length }} schrijven. Beide werken; het eerste houdt de template simpel.

Modelantwoord oefening 2
{% extends "base.html" %}
{% block titel %}Toevoegen{% endblock %}
{% block inhoud %}
  {% if fout %}<p class="fout">{{ fout }}</p>{% endif %}
  <form method="post">
    <label>Product <input name="product" value="{{ product|default('') }}"></label>
    <label>Prijs <input name="prijs" value="{{ prijs|default('') }}"></label>
    <button>Toevoegen</button>
  </form>
{% endblock %}

In de route: return render_template("toevoegen.html", fout=fout, product=product, prijs=prijs_tekst), 400 bij een fout en render_template("toevoegen.html") bij een GET. De ingevulde waarden blijven staan, zodat de gebruiker niet alles opnieuw hoeft te typen.

Modelantwoord oefening 3
<tr{% if prijs > 5 %} class="duur"{% endif %}>

prijs is een Decimal; vergelijken met het getal 5 mag gewoon.

Modelantwoord oefening 4
{% macro bedragveld(naam, label, waarde="") %}
  <label>{{ label }}
    <input name="{{ naam }}" value="{{ waarde }}" inputmode="decimal" pattern="[0-9]+([,.][0-9]{1,2})?">
  </label>
{% endmacro %}

pattern helpt de gebruiker in de browser, maar vervangt de controle op de server niet: iemand met curl slaat de browser gewoon over.

Lokaal oefenen

Deze cursus heeft geen live lab: Flask, een database en een webserver passen niet in de kleine lab-container. Je werkt in je eigen kassa-web-map. Controleer jezelf met flask --app app routes en met curl -i op elke pagina.

Samenvatting

  • Templates staan in templates/ en render je met render_template("naam.html", var=waarde).
  • {{ }} vult in (en escapet automatisch), {% %} stuurt (lus, if, block), filters bewerken met |.
  • base.html met {% block %} + {% extends %} geeft alle pagina's dezelfde layout.
  • In les 4 krijgt die layout CSS en een logo via statische bestanden.

📝 Quiz: Jinja-templates en overerving

6 vragen · 70% nodig om te slagen

1. Waar zoekt Flask standaard naar templates?
2. Een gebruiker vult als product `<script>alert(1)</script>` in. Wat doet `{{ product }}` in bon.html?
3. Wat toont `{% for p in lijst %}{{ p }}{% else %}leeg{% endfor %}` bij een lege lijst?
4. Waarvoor dient `{% extends "base.html" %}`?
5. Wanneer is `|safe` acceptabel?
6. Welke fout krijg je als je `{% endblock %}` vergeet?