Flask 3 - ‘Flaskr’ Tutorial
Inleiding
Tijdens deze les volgen we (grotendeels) de officiële Flask tutorial. Die zit goed in elkaar, gebruikt van alle belangrijkste functies, en je kan er gemakkelijk online extra informatie over vinden.
Tijdens de tutorial maken we een eenvoudige blog applicatie, “Flaskr”. Gebruikers zullen kunnen registreren, inloggen en hun eigen posts maken, aanpassen of verwijderen.
Flask is erg flexibel en je kan een project op verschillende manieren organiseren. De tutorial geeft een gestructureerd voorbeeld waar heel wat best-practices in verweven zitten.
Project Layout en Initialisatie
Project nieuwe directory aanmaken
mkdir flask-tutorial
cd flask-tutorialAlle paden en bestandsnamen die we vanaf nu gebruiken zijn relatief t.o.v. deze directory.
uv of conda environment initialiseren
uv init
uv add "Flask~=3.0"conda create -n flask
conda activate flask
conda install "Flask~=3.0"
conda activate flask.gitignore file toevoegen
Als je uv gebruikt dan is de standaard .gitignore een prima start. We voegen *.sqlite zodat we nooit de database bestand(en) willen committen.
.gitignore
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# SQLite database files
*.sqliteGit initialiseren
git init
git add .
git commit -m "Project init"Zoals bij elke project doe je er goed aan regelmatig je vooruitgang de committen.
Project Structuur
Het project krijgt de volgende basis-structuur:
- 1
-
flaskris een Python package voor de applicatie - 2
-
in
templates/komen alle jinja2 templates - 3
-
in
static/komen alle statische elementen zoals css, afbeeldingen, … - 4
-
in
tests/komen later de unit tests
Application Setup
We weten al dat we één enkele instance van de Flask class zullen hebben, typisch app genoemd. In plaats van dit object rechtstreeks aan te maken (zoals we tot nu toe gedaan hebben) gebruiken we het Factory Pattern. Hierbij maken we een aparte functie die het Flask object terug geeft. Dat is handig om het uiteindelijke object anders te configureren naargelang de omgeving (tests, productie, …).
flaskr/__init__.py
- 1
-
Flask verwacht ofwel een globale
appvariabele, ofwel eencreate_appfactory functie. We zullen dus nergenscreate_appexpliciet moeten aanroepen. - 2
-
Als eerste stap moeten we natuurlijk een
Flaskapp object maken. - 3
-
Omdat we cookies willen gebruiken (via het
flask.sessionobject) hebben we een key nodig. Om te starten gebruiken we een dummy key maar later moeten we dit beter beveiligen. - 4
-
Voor het renderen van Jinja templates willen we altijd autoescape gebruiken. Op deze manier kunnen we onze templates de extensie
.html.j2geven. - 5
- We voegen direct een simpele test-route toe aan de applicatie. Deze code kan je later verwijderen.
SQLite is ideaal om mee te starten omdat er geen server installatie nodig is en alles in één enkel bestand wordt beheerd. We kunnen dan later ook bekijken hoe je dankzij SQLAlchemy gemakkelijk naar een andere database kan overstappen, zoals PostgreSQL.
Bovendien mag SQLite tegenwoordig zeker als een volwaardige database systeem aanzien worden. SQLite ondersteunt ACID-transacties en volgt het SQL-standaarden. Heel wat applicaties gebruiken SQLite standaard als database, bvb. iMessage, Google Chrome of Dropbox.
We testen de functie direct uit. Merk op dat Flask automatisch de create_app functie in de module uitvoert.
$ flask --app flaskr run --debug
* Serving Flask app 'flaskr'
* Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
* Restarting with stat
* Debugger is active!
* Debugger PIN: 644-305-120Database Toegang
De applicatie zal een SQLite database gebruiken om de gebruikers en hun posts op de slaan. Zoals bij de meeste andere databases zullen we een Connection object nodig hebben om met de database te werken.
Database Connection
Met de volgende module voorzien we helper-functions om op een slimme manier een database connection te verkrijgen en ook terug af te sluiten.
Deze module is ook een mooi voorbeeld van het gebruik van de g en current_app proxies uit Les 1 - De Application Context.
flaskr/db.py
import sqlite3
from pathlib import Path
from flask import g
1LOCAL_DIRECTORY = Path(__file__).parent
SQLITE_DB_FILE = LOCAL_DIRECTORY / "flaskr.sqlite"
def get_db() -> sqlite3.Connection:
2 if "db" not in g:
3 g.db = sqlite3.connect(SQLITE_DB_FILE)
4 g.db.row_factory = sqlite3.Row
return g.db
def close_db(_exc=None):
if "db" in g:
5 g.db.close()- 1
-
pathlibis een handige module uit the Python standard library om met bestanden en directories te werken. MetPath(__file__).parentkrijgen we de directory waardb.pyzich bevindt. Op die manier zijn we zeker dat we altijd met hetzelfde sqlite bestand werken. - 2
-
De
sqlite3.Connectionvoor een bepaaldeRequestwordt bewaard in hetflask.gobject. Op die manier wordt er maar één connection per request aangemaakt. - 3
- Om eenvoudig te starten gebruiken we vast SQLite database bestand. Later zullen we dit configureerbaar willen maken.
- 4
-
Deze configuratie zorgt ervoor dat de
sqlite3.Connectionrows zal returnen als dictionaries i.p.v. tuples. Dit is iets handiger om met de data te werken. - 5
- Enkel als een connection bestaat proberen we deze te sluiten. We zien later hoe ervoor gezorgd wordt dat deze functie op het einde van elke request wordt uitgevoerd.
Tables
We gebruiken standaard SQL commandos om de tables in de SQLite database aan te maken. We hebben maar 2 tables nodig, één om de gebruikers op de slaan, en één voor de blogposts.
flaskr/schema.sql
DROP TABLE IF EXISTS user;
DROP TABLE IF EXISTS post;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE post (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author_id INTEGER NOT NULL,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
title TEXT NOT NULL,
body TEXT NOT NULL,
FOREIGN KEY (author_id) REFERENCES user (id)
);We breiden db.py uit zodat we het kunnen uitvoeren om bovenstaande schema toe te passen op een SQLlite database.
flaskr/db.py
# bestaande code...
def init_db():
1 db = sqlite3.connect(SQLITE_DB_FILE)
2 with open(LOCAL_DIRECTORY / "schema.sql") as schema:
db.executescript(schema.read())
print("SQLite schema created. Tables in DB:")
3 result = db.execute("SELECT name FROM sqlite_master WHERE type='table'")
print(result.fetchall())
\
4if __name__ == "__main__":
init_db()- 1
- We maken een verbinding met hetzelfde database bestand.
- 2
-
Net als het SQLite bestand verwachten we dat
schema.sqlin dezelfde directory staat als dedb.pymodule zelf. Metopenenreadlezen we dat bestand in en voeren het uit als een script in de database, met de functieexecutescript. - 3
- Achteraf controleren we welke tables effectief bestaan.
- 4
-
Deze code zal enkel worden uitgevoerd als we het
db.pybestand rechtstreeks uitvoeren, niet als het gewoon geïmporteerd wordt.
Deze code moeten we één maal uitvoeren zodat de database klaar is voor gebruik.
Registreren met de Applicatie
We willen dat de close_db functie uit de db.py module uitgevoerd wordt op het einde van elke request.
We passen de factory functie als volgt aan:
flaskr/db.py
- 1
- De `db module moet natuurlijk geïmporteerd worden.
- 2
-
teardown_appcontextregistreert functies die uitgevoerd moeten worden als de “Application Context” (gobject) verwijderd wordt (op het einde van elke request dus).
Blueprints en Views
Een eerste Blueprint
Een view is een functie die bepaalt wat er gebeurt en teruggestuurd wordt voor een bepaalde route.
Een blueprint is een manier om een groep relateerde views te organiseren. De flaskr applicatie zal twee blueprints gebruiken: een voor authenticatie en een voor blogposts.
We starten met de basis voor authenticatie. Om de code gestructureerd en georganiseerd te houden werken we opnieuw in een aparte module.
De Blueprint heeft:
- Een vrij te kiezen naam, hier gewoon
auth - De naam van de package of module waartoe de
Blueprintbehoort. Net zoals voor deFlaskapp gebruiken we__name__(dus uiteindelijkflaskr) - Een
url_prefixdie voor het pad van elke view route wordt geplaatst om zo het volledige pad van die route te bepalen.
In de factory gebruiken we de app.register_blueprint functie om het Blueprint object toe te voegen aan de applicatie.
Eerste Auth View: register
Als een gebruiker de /auth/register pagina bezoekt (GET) moet deze view HTML terugsturen met een form die de username en password velden bevat. Bij het verzenden van de form (POST) moet de view beide velden valideren, een nieuwe gebruiker opslaan en doorsturen naar de login pagina.
flaskr/auth.py
from flask import (
Blueprint,
flash,
g,
redirect,
render_template,
request,
session,
url_for,
)
from werkzeug.security import check_password_hash, generate_password_hash
from flaskr.db import get_db
bp = Blueprint("auth", __name__, url_prefix="/auth")
1@bp.route("/register", methods=("GET", "POST"))
def register():
2 if request.method == "POST":
username = request.form["username"]
password = request.form["password"]
error = None
3 if not username:
error = "Username is required."
elif not password:
error = "Password is required."
if error is None:
4 db = get_db()
try:
5 db.execute(
"INSERT INTO user (username, password) VALUES (?, ?)",
6 (username, generate_password_hash(password)),
)
db.commit()
7 except db.IntegrityError:
error = f"User {username} is already registered."
else:
8 return redirect(url_for("auth.login"))
9 flash(error)
10 return render_template("auth/register.html.j2")- 1
-
Een view aan een blueprint route koppelen werkt op precies delfde manier als rechtstreeks in de app. I.p.v.
app.routegebruiken webp.route. - 2
-
Het
requestobject gebruiken om de HTTP method en form te bekijken kennen we al. - 3
- Zowel de username als het password mogen uiteraard niet leeg zijn.
- 4
-
De
get_dbfunctie uit onze eigendbmodule geeft eenConnectionobject om met de database te werken. - 5
-
Met de
Connection.executekunnen we standaard SQL commandos naar de database sturen. Een commit naar de database moet expliciet gebeuren via decommitfunctie. - 6
-
Het password bewaren we natuurlijk niet in zijn oorspronkelijke vorm. Met de
generate_password_hashfunctie uit dewerkzeugpackage verkrijgen we een hash van het password. - 7
-
Als er reeds een database row bestaat met dezelfde username zal de
executefunctie eenIntegrityErrorexception geven (dankzij deUNIQUEconstraint in het schema). In dat geval bereiden we een verklarende foutmelding voor. - 8
-
Als alles is gelukt sturen we de gebruiker door naar de login pagina.
"auth.login"slaat op deloginview in theauthblueprint en die moeten we natuurlijk nog maken. - 9
-
Met de
flask.flashfunctie kunnen we een string registreren die we later in de template zullen gebruiken om de aandacht van de gebruiker te trekken. - 10
- We gebruiken een template (deze bekijken we in het volgende hoofdstuk) om de registratiepagina terug te sturen. Dit gebeurt in twee scenarios:
Meer weten over:
- Placeholders (bound parameters) in sqlite commandos: https://docs.python.org/3/library/sqlite3.html#sqlite3-placeholders
- Werkzeug: https://werkzeug.palletsprojects.com/en/stable/
generate_password_hash: https://werkzeug.palletsprojects.com/en/stable/utils/#werkzeug.security.generate_password_hash
Wat gebeurt er als we nu al de pagina http://127.0.0.1:5000/auth/register proberen te bezoeken?
Testen met eenvoudige Template
Om deze eerste route te kunnen uitproberen voegen we een eenvoudige tijdelijke template toe:
flaskr/templates/auth/register.html.j2
<!doctype html>
<html>
<body>
<h1>Register</h1>
<p style="color:red; font-weight: bold;">
1 {% for message in get_flashed_messages() %}
{{ message }}
{% endfor %}
</p>
2 <form method="post">
<label for="username">Username</label>
<input name="username" id="username" required>
<label for="password">Password</label>
<input type="password" name="password" id="password" required>
<input type="submit" value="Register">
</form>
</body>
</html>- 1
-
De
get_flashed_messages()functie geeft een lijst terug met alle berichten (strings) waarmee deflashuitgevoerd werd tijdens deze request. Op die manier kunnen we (optioneel) problemen uit de laatste POST melden aan de gebruiker. - 2
-
Een typische login form, met velden voor
usernameenpasswordwordt gepresenteerd.
We kunnen nu in principe de route al voor een deel uittesten.
Via http://127.0.0.1:5000/auth/register kunnen we nu een gebruiker registreren. Als we 2x dezelfde username proberen registreren moeten we een foutmelding krijgen.
Tweede Auth View: login
Nadat een gebruiker geregistreerd is moet deze natuurlijk kunnen inloggen.
Oefening
Probeer zelf als oefening (in groepen) de view functie te schrijven.
De login view volgt dezelfde structuur als de register view:
- Een GET request geeft een login form terug
- De ingevulde login form wordt gePOST
- De form velden worden gevalideerd en dit bepaalt de response
- Fout: zelfde pagina en toon foutmelding
- Juist:
user_iduit database opslaan in cookie ; doorsturen naar index pagina
- Met
db.execute("SQL QUERY TEXT").fetchone()krijg je de eerst rij van het resultaat van de query, ofNone. - Met
check_password_hash(hash, password) -> boolkan je controleren ofhashde hash is die hoort bijpassword. - Start eenvoudig en voeg stap voor stap functionaliteit toe.
Oplossing
flaskr/templates/auth/login.html.j2
<!doctype html>
<html>
<body>
<h1>Login</h1>
<p style="color:red; font-weight: bold;">
{% for message in get_flashed_messages() %}
{{ message }}
{% endfor %}
</p>
<form method="post">
<label for="username">Username</label>
<input name="username" id="username" required>
<label for="password">Password</label>
<input type="password" name="password" id="password" required>
<input type="submit" value="Login">
</form>
</body>
</html>flaskr/auth.py
# Bestaande code
@bp.route("/login", methods=("GET", "POST"))
def login():
if request.method == "POST":
username = request.form["username"]
password = request.form["password"]
db = get_db()
error = None
1 user_row = db.execute(
"SELECT * FROM user WHERE username = ?", (username,)
).fetchone()
2 if user_row is None:
error = "Incorrect username."
3 elif not check_password_hash(user_row["password"], password):
error = "Incorrect password."
if error is None:
4 session.clear()
session["user_id"] = user_row["id"]
return redirect(url_for("blog.index"))
flash(error)
return render_template("auth/login.html.j2")- 1
-
We gebruiken opnieuw
Connection.executeom SQL naar de database te sturen. We zoeken de username op in de user table. Metfetchonekrijgen we enkel de eerste row. Door de eerdere configuratie vandb.row_factory = sqlite3.Rowkrijgen we de row als een dictionary terug. Als er geen row is gevonden krijgen weNoneterug. - 2
- Als we de username niet kunnen vinden is dit uiteraard een foute request. Is het wel een goed idee om hier een specifieke foutmelding voor te geven?
- 3
-
check_password_hashis de tegenhanger vangenerate_password_hashen controleert of de het eerste argument overeenstemt met de hash van het tweede argument. - 4
-
Als het password correct was gebruiken we het
sessionobject (uit les 1 - de application context) om de database ID van de user te bewaren. Daarna sturen we de request door naar de index pagina (die we later zullen maken).
session zal dit ook opgeslagen worden in een cookie in de browser van de gebruiker en bij volgende request meegestuurd worden. Op die manier kunnen we weten dat volgende requests al ingelogd zijn zonder dat de username en password meegestuurd moeten worden. Maar is dit wel veilig?
User automatisch laden
We willen dus eigenlijk dat voor elke request gecontroleerd wordt of een user_id in de meegestuurde cookie staat. Bovendien zou het handig zijn om direct de details van die user op te halen uit de database. Flask heeft hiervoor een handige functie die we hieronder bekijken. Deze code voegen we toe in auth.py.
flaskr/auth.py
- 1
-
before_app_requestis net zoalsrouteeen decorator maar i.p.v. een gewone view functie die enkel bij een bepaald route (URL pad) hoort zal deze functie voor elke request worden uitgevoerd. En dit, vóór dat de eigenlijke view functie wordt uitgevoerd. - 2
-
We gaan the de
flask.gproxy (uit les 1 - de request context) gebruiken om de details van de user op de slaan. Op dit manier zullen ze beschikbaar zijn voor de volledige request. Als er geenuser_idte vinden is in desessionkunnen we ook niets ophalen uit de database en initialiseren weg.usermetNone. Dit wordt later dan ook de aanduiding dat de gebruiker nog moet inloggen. - 3
-
Als er wél een
user_idis gebruiken we deze om de volledige row van die user op te zoeken en bewaren het resultaat ing.user.
Om nu te controleren of een request wel van een ingelogde gebruiker komt (een geautoriseerde request) moeten we gewoon controleren of g.user iets bevat (dus niet None is). Hiervoor maken we zelf een nieuwe decorator die we dan kunnen toevoegen aan specifieke toekomstige view functies die enkel toegankelijk zijn voor ingelogde gebruikers. Hoe decorators, en specifiek zelfgemaakte decorators, precies werken komt aan bod in de module Python Architectuur.
flaskr/auth.py
- 1
-
We maken een eigen
login_requireddecorator. Die zullen we kunnen gebruiken met@login_requiredboven bepaalde view functies. - 2
-
Als er geen
g.userdata is sturen we de request direct door naar de login pagina. Verder gebeurt er niets in deze decorator. - 3
- Anders wordt de gedecoreerde functie gewoon uitgevoerd.
Uitloggen
Om een gebruiker uit te loggen moeten we dan eenvoudigweg de informatie opgeslagen in de cookie verwijderen.
Templates
In dit hoofdstuk bekijken we de verschillende Jinja2 templates die we nodig hebben in de views van the auth module uit het vorig deel:
auth/register.html.j2auth/login.html.j2
De Base Layout
Elke pagina zal een aantal identieke basis-elementen bevatten. I.p.v. deze in elke template te herhalen kunnen we template inheritance gebruiken en eenzelfde template als basis maken voor alle andere templates.
flaskr/templates/base.html.j2
<!doctype html>
1<title>{% block title %}{% endblock %} - Flaskr</title>
2<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
3<nav>
<h1>Flaskr</h1>
<ul>
4 {% if g.user %}
<li><span>{{ g.user['username'] }}</span>
<li><a href="{{ url_for('auth.logout') }}">Log Out</a>
{% else %}
<li><a href="{{ url_for('auth.register') }}">Register</a>
<li><a href="{{ url_for('auth.login') }}">Log In</a>
{% endif %}
</ul>
</nav>
<section class="content">
<header>
{% block header %}{% endblock %}
</header>
5 {% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% block content %}{% endblock %}
</section>- 1
-
De
title,headerencontentblocks blijven hier leeg en zullen worden ingevuld in de individuele templates. Het zijn hier dus louter placeholders. - 2
-
De
url_forfunctie is automatisch beschikbaar in templates. - 3
- Bovenaan de pagina willen we navigatie-elementen
- 4
-
Ook het
flask.gobject is automatisch beschikbaar. Hier kunnen we direct de user details uit het vorige hoofdstuk gebruiken: als de gebruiker ingelogd is tonen we zijn username en een link naar de logout pagina. Zo niet, dan tonen we links naar de register and login pagina’s. - 5
-
Met de
get_flashed_messagesfunctie krijgen we een lijst met alle strings die in the view functies met deflashfunctie zijn aangemaakt.
De Register Template
flaskr/templates/auth/register.html.j2
1{% extends 'base.html.j2' %}
2{% block header %}
<h1>{% block title %}Register{% endblock %}</h1>
{% endblock %}
{% block content %}
3<form method="post">
<label for="username">Username</label>
<input name="username" id="username" required>
<label for="password">Password</label>
<input type="password" name="password" id="password" required>
<input type="submit" value="Register">
</form>
{% endblock %}- 1
- Alle individuele templates zullen de base template uitbreiden
- 2
-
Op deze manier overschrijven we de (initieel lege)
headerentemplateblokken uit de base template. The inhoud zal komen te staan op de plaats van deze blokken in de base template. - 3
- Voor de register pagina tonen we een eenvoudige form waar een username en password worden ingevuld.
De Login Template
De login template is eigenlijk identiek aan de register template. Alleen de title en de submit knop verschillen.
flaskr/templates/auth/login.html.j2
{% extends 'base.html.j2' %}
{% block header %}
<h1>{% block title %}Log In{% endblock %}</h1>
{% endblock %}
{% block content %}
<form method="post">
<label for="username">Username</label>
<input name="username" id="username" required>
<label for="password">Password</label>
<input type="password" name="password" id="password" required>
<input type="submit" value="Log In">
</form>
{% endblock %}Een Eerste Test
Met de uitgewerkte auth views en templates moeten we al in staat zijn een user aan te registreren en in te loggen.
flask --app flaskr run --debug- Ga naar http://127.0.0.1:5000/auth/register en geef een username and password in.
- Je wordt automatisch doorgestuurd naar http://127.0.0.1:5000/auth/login.
- Log in met dezelfde gegevens.
- Je krijgt een fout omdat de
indexview nog niet bestaat
Een SQLite database bestand is aangemaakt in instance/flaskr.sqlite. Met de sqlite3 CLI kunnen we de database bekijken. Kijk op https://sqlite.org/index.html hoe je dit kan installeren voor jou systeem.
$ sqlite3 instance/flaskr.sqlite
SQLite version 3.45.3 2024-04-15 13:34:05
Enter ".help" for usage hints.
sqlite> .schema
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE post (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author_id INTEGER NOT NULL,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
title TEXT NOT NULL,
body TEXT NOT NULL,
FOREIGN KEY (author_id) REFERENCES user (id)
);
sqlite> select * from user;
1|fdevaux|scrypt:32768:8:1$nDOqwkuk4IMOTlIS$b4c25d6577cd213c2963f31aba0591cffa3d878e6b4af0da5a459410a131583a15c103810dc51020a0abdd3bdc0fbbe66dbfea3bbd6fecc6bc116c270da81689Static Files
Met CSS zorgen we dat de pagina’s er iets beter uitzien. Het CSS bestand is statisch, want het is hetzelfde voor elke request. Daarom plaatsen we het in een flaskr/static directory (i.p.v. flaskr/templates). Met de functie url_for('static', filename='style.css') kunnen we de juiste URL naar het bestand genereren. Dit hebben we al gebruikt in de base.html.j2 template.
flaskr/static/style.css
html { font-family: sans-serif; background: #eee; padding: 1rem; }
body { max-width: 960px; margin: 0 auto; background: white; }
h1 { font-family: serif; color: #377ba8; margin: 1rem 0; }
a { color: #377ba8; }
hr { border: none; border-top: 1px solid lightgray; }
nav { background: lightgray; display: flex; align-items: center; padding: 0 0.5rem; }
nav h1 { flex: auto; margin: 0; }
nav h1 a { text-decoration: none; padding: 0.25rem 0.5rem; }
nav ul { display: flex; list-style: none; margin: 0; padding: 0; }
nav ul li a, nav ul li span, header .action { display: block; padding: 0.5rem; }
.content { padding: 0 1rem 1rem; }
.content > header { border-bottom: 1px solid lightgray; display: flex; align-items: flex-end; }
.content > header h1 { flex: auto; margin: 1rem 0 0.25rem 0; }
.flash { margin: 1em 0; padding: 1em; background: #cae6f6; border: 1px solid #377ba8; }
.post > header { display: flex; align-items: flex-end; font-size: 0.85em; }
.post > header > div:first-of-type { flex: auto; }
.post > header h1 { font-size: 1.5em; margin-bottom: 0; }
.post .about { color: slategray; font-style: italic; }
.post .body { white-space: pre-line; }
.content:last-child { margin-bottom: 0; }
.content form { margin: 1em 0; display: flex; flex-direction: column; }
.content label { font-weight: bold; margin-bottom: 0.5em; }
.content input, .content textarea { margin-bottom: 1em; }
.content textarea { min-height: 12em; resize: vertical; }
input.danger { color: #cc2f2e; }
input[type=submit] { align-self: start; min-width: 10em; }Als je nu http://127.0.0.1:5000/auth/login bekijkt zou het er (iets) beter moeten uitzien.
De Blog Blueprint
Blueprint Basis
In auth.py hebben we een Blueprint gemaakt voor alle authenticatie views. Op dezelfde manier maken we nu een nieuwe blog.py module met een Blueprint voor alle blog views.
Uiteindelijk zullen we volgende views nodig hebben:
- Index, om alle blogposts van alle gebruikers te laten zien.
- Create, om een ingelogde gebruiker een nieuwe post te laten schrijven.
- Update, om een ingelogde gebruiker een bestaande post te laten aanpassen of verwijderen.
We starten met enkel het Blueprint object en de nodige imports.
flaskr/blog.py
- 1
-
Onze eigen
login_requireddecorator zullen we nodig hebben voor de views waarmee een blogpost bewerkt wordt. - 2
-
Ook
get_dbhebben we opnieuw nodig, ditmaal om blogpost naar de database weg te schrijven en terug op te halen. - 3
-
In tegenstelling tot de auth
Blueprintgeven we hier geenurl_prefixargument mee. Hierdoor komen alle routes voor dezeBlueprintin de ‘root’ van de URL terug (bvb. http://127.0.0.1:5000/create).
Ook deze Blueprint moeten we in de app registreren:
De Index View
De index pagina zal alle blogpost laten zien, van nieuw naar oud.
flaskr/blog.py
- 1
-
Door enkel
/in het pad van de route te gebruiken wordt deze toegankelijke als http://127.0.0.1:5000. - 2
-
Met een
JOINtussen beide tables halen we de informatie op van elke post maar ook van de auteur van de post. - 3
-
Met de
fetchallfunctie krijgen we een lijst van alle rows. Elk item in die lijst stelt 1 row voor, maar krijgen we opnieuw als een dictionary. - 4
-
Door die lijst (
posts) mee te geven in derender_templatefunctie zullen we deze kunnen gebruiken in de template.
flaskr/templates/blog/index.html.j2
{% extends 'base.html.j2' %}
{% block header %}
<h1>{% block title %}Posts{% endblock %}</h1>
1{% if g.user %}
<a class="action" href="{{ url_for('blog.create') }}">New</a>
{% endif %}
{% endblock %}
{% block content %}
2{% for post in posts %}
<article class="post">
<header>
<div>
3 <h1>{{ post['title'] }}</h1>
<div class="about">by {{ post['username'] }} on {{ post['created'] }}</div>
</div>
4 {% if g.user['id'] == post['author_id'] %}
<a class="action" href="{{ url_for('blog.update', id=post['id']) }}">Edit</a>
{% endif %}
</header>
<p class="body">{{ post['body'] }}</p>
</article>
{% if not loop.last %}
<hr>
{% endif %}
{% endfor %}
{% endblock %}- 1
-
Voor ingelogde gebruikers (m.a.w. als
g.usernietNoneis) voegen we een extra link toe naar de (toekomstige) pagina om een nieuwe post te maken. - 2
-
Voor de inhoud van de pagina itereren we over de lijst van
postsdie wordt meegegeven aan de template. Depostvariable zal dus telkens een dictionary zijn met alle velden uit de database query. - 3
-
Zo is
post['title']bijvoorbeeld de titel van één post en gebruiken we dit voor de H1 header. - 4
-
Als de ID van de auteur van een post hetzelfde is als de ID van de ingelogde gebruiker dan voegen we (voor die specifieke post) een link toe naar de pagina om een post aan te passen. In de
url_forfunctie kunnen extra argumenten toegevoegd worden die als parameters zullen worden meegestuurd. Hier geven we de ID van de post mee zodat deblog.updateview de juiste post kan laten bewerken.
De Create View
Deze view moet opnieuw zowel GET als POST methodes ondersteunen. Bij een GET wordt een form weergegeven met alle nodige velden om een nieuw post de schrijven. Bij een POST wordt de form data gevalideerd en in de database opgeslagen. Bij success wordt er ook hier geredirect naar de index pagina.
Probeer zelf als oefening (in groepen) de template en view functie te schrijven.
- De form moet enkel de titel (
title) en body van de blogpost bevatten. - Voor de waarde van
author_idgebruiken we de user_id, die al beschikbaar is in hetgobject. - Start eenvoudig en voeg stap voor stap functionaliteit toe.
Oplossing
flaskr/blog.py
@bp.route("/create", methods=("GET", "POST"))
1@login_required
def create():
if request.method == "POST":
title = request.form["title"]
body = request.form["body"]
error = None
if not title:
error = "Title is required."
if error is not None:
flash(error)
else:
db = get_db()
db.execute(
2 "INSERT INTO post (title, body, author_id) VALUES (?, ?, ?)",
(title, body, g.user["id"]),
)
db.commit()
return redirect(url_for("blog.index"))
return render_template("blog/create.html.j2")- 1
- Om een nieuwe post te maken moet de gebruiker ingelogd zijn.
- 2
-
Voor de
author_idgebruiken weg.user["id"]. Deze is zeker beschikbaar dankzij onzelogin_requireddecorator.
De bijhorende template toont een eenvoudige form met een veld voor de post title en een voor de body. Voor die laatste gebruiken we een textarea element (i.p.v. input) om een groter tekstvak te krijgen.
flaskr/templates/blog/create.html.j2
{% extends 'base.html.j2' %}
{% block header %}
<h1>{% block title %}New Post{% endblock %}</h1>
{% endblock %}
{% block content %}
<form method="post">
<label for="title">Title</label>
<input name="title" id="title" value="{{ request.form['title'] }}" required>
<label for="body">Body</label>
<textarea name="body" id="body">{{ request.form['body'] }}</textarea>
<input type="submit" value="Save">
</form>
{% endblock %}Tussendoor Testen
Het is altijd aan te raden nieuwe code te testen van zodra dat mogelijk is. Hoe meer nieuwe code je schrijft tussen twee testen, hoe moeilijker het kan zijn een fout te debuggen.
We zouden nu in staat moeten zijn een post te schrijven en vervolgende de bestaande post(s) te bekijken.
- Als we als ingelogde gebruiker http://127.0.0.1:5000 bezoeken zien we natuurlijk nog geen posts, maar wel een nieuwe
Newlink. - Via die link komen we op http://127.0.0.1:5000/create waar we een post kunnen schrijven en opslaan.
- We worden terug doorgestuurd naar http://127.0.0.1:5000 maar hier krijgen we een fout … omdat de
blog.updateview nog niet bestaat. Geen probleem dus. Als we uitloggen zouden we de post wel moeten zien (waarom?)
De Update View
Voor zowel de update als de delete views moet een post uit de database worden opgehaald (op basis van een post ID). Er moet ook gecontroleerd worden dat de ingelogde gebruiker wel degelijk de auteur is van de post.
We maken een aparte functie voor deze gemeenschappelijke functionaliteit:
flaskr/blog.py
def get_post(id: int) -> dict:
post = (
get_db()
1 .execute(
"SELECT p.id, title, body, created, author_id, username"
" FROM post p JOIN user u ON p.author_id = u.id"
" WHERE p.id = ?",
(id,),
)
.fetchone()
)
2 if post is None:
abort(404, f"Post id {id} doesn't exist.")
if post["author_id"] != g.user["id"]:
abort(403)
return post- 1
-
We gebruiken de gegeven
idom een post uit deposttable op te halen. - 2
-
De
idmoet natuurlijk bestaan. Deauthor_idvan de post moet kloppen met de id van de ingelogde gebruiker. Als dit niet het geval is moet een fout worden teruggestuurd. Hiervoor gebruiken we deflask.abortfunctie. Deze stop verdere afhandeling van de request en stuurt een response terug met de aangegeven status code. Zijn 404 en 403 goede codes in dit geval?
Met behulp van deze functie kunnen we nu de update view schrijven. Die lijkt natuurlijk erg op de create view.
flaskr/blog.py
1@bp.route("/<int:id>/update", methods=("GET", "POST"))
@login_required
def update(id):
post = get_post(id)
if request.method == "POST":
title = request.form["title"]
body = request.form["body"]
error = None
if not title:
error = "Title is required."
if error is not None:
flash(error)
else:
db = get_db()
db.execute(
2 "UPDATE post SET title = ?, body = ? WHERE id = ?", (title, body, id)
)
db.commit()
return redirect(url_for("blog.index"))
3 return render_template("blog/update.html.j2", post=post)- 1
-
De ID van de post maakt deel uit van het pad van de route (bvb.
/123/update). - 2
-
I.p.v.
INSERTgebruiken weUPDATEom een bestaande database row aan te passen - 3
- De details van de bestaande post worden meegeven aan de template zodat de post kan worden weergegeven in de pagina.
Ook deze template toont een eenvoudige form met velden voor de post informatie.
flaskr/templates/blog/update.html.j2
{% extends 'base.html.j2' %}
{% block header %}
<h1>{% block title %}Edit "{{ post['title'] }}"{% endblock %}</h1>
{% endblock %}
{% block content %}
<form method="post">
<label for="title">Title</label>
1 <input name="title" id="title" value="{{ request.form['title'] or post['title'] }}" required>
<label for="body">Body</label>
<textarea name="body" id="body">{{ request.form['body'] or post['body'] }}</textarea>
<input type="submit" value="Save">
</form>
<hr>
<form action="{{ url_for('blog.delete', id=post['id']) }}" method="post">
<input class="danger" type="submit" value="Delete" onclick="return confirm('Are you sure?');">
</form>
{% endblock %}- 1
-
Voor de
titleenbodyinput velden van de form wordt in principe de informatie van de huidige post al ingevuld (bvb.post['title']). Maar als er een fout optreedt bij het verwerken van de POST komt de gebruiker op dezelfde pagina terug. In dit geval willen we de informatie uit de geposte form behouden (bvb.request.form['body'])
De Delete View
De delete view heeft geen template nodig. De delete knop in de update template POST naar de route van de delete view (/<post_id>/delete). De delete view stuurt de gebruiker gewoon terug naar de index pagina.
Klaar!
De applicatie is klaar. Test zelf de verschillende functies uit. Controleer bijvoorbeeld dat elke gebruiker enkel zijn eigen posts kan bewerken. Probeer ook wat er gebeurt als je dit probeert te omzeilen.
Testen
Je applicatie handmatig uittesten is leuk maar het wordt snel problematisch als je voor elke toekomstige aanpassing alle bestaande functionaliteit met de hand moet controleren.
Gelukkig voorziet Flask een aantal handige tools om eenvoudig je applicatie functioneel te testen.
Eerste Stappen
Pytest
Om testen uit te voeren gebruiken we pytest.
uv add pytestconda install pytestFlask TestClient
De functie app.test_client() geeft een flask.testing.FlaskClient object terug.
Met dit object kan je gemakkelijk verschillende test requests sturen naar het app object, zonder een browser of andere externe tools te moeten gebruiken.
Bijvoorbeeld:
tests/test_auth.py
$ pytest tests/test_auth.py
============================== test session starts ==============================
platform darwin -- Python 3.13.5, pytest-8.4.2, pluggy-1.6.0
rootdir: /Users/dfabrice/dev/github/flaskr
configfile: pyproject.toml
collected 1 item
tests/test_auth.py . [100%]
=============================== 1 passed in 0.08s ===============================Het is mogelijk dat je volgende foutmelding krijgt:
ImportError while loading conftest '.../flaskr/tests/test_auth.py'.
tests/conftest.py:4: in <module>
from flaskr import create_app
E ModuleNotFoundError: No module named 'flaskr'
Dit komt omdat ons flaskr package niet “geïnstalleerd” is en de directory waaruit we ontwikkelen niet doorzocht wordt door Python.
Hier zijn verschillende oplossing voor maar een van de eenvoudigste en binnen het kader van pytest, is om deze te configureren om de lokale project directory toe te voegen aan het PYTHONPATH tijdens het uitvoeren van de testen.
Dit kan eenvoudig door het volgende toe te voegen in het pyproject.toml bestand:
[tool.pytest.ini_options]
pythonpath = ["."]
Test Database
De vorige test is op zich veilig, omdat het om een eenvoudige GET request gaat die geen wijzigingen uitvoert. Maar wat als we registratie volledige willen testen? Of het maken van nieuwe posts.
Een van de mogelijke oplossing hiervoor is om tijdens de testen een ‘in-memory’ SQLite database te gebruiken. Hierbij bewaart SQLite alles enkel in het geheugen. Dit doen we door aan sqlite3.connect de string :memory: mee te geven (i.p.v. de naam van een bestand).
Een goed moment om onze create_app factory configureerbaar te maken:
flaskr/__init__.py
- 1
-
create_appheeft nu een optioneledatabaseparameter. Als default behouden we gewoon"flaskr.sqlite". - 2
-
De waarde van die parameter slaan we op onder de zelfgekozen
"DATABASE"key in deconfigdictionary van hetFlaskapp object.
In de get_db functie van de db.py module moeten we dan ook die zelfde configuratie gebruiken om een verbinding met de database te maken:
flaskr/db.py
- 1
-
current_appgeeft altijd het huidigeFlaskapp object terug. - 2
-
Op die manier krijgen we via
current_app.config["DATABASE"]de geconfigureerde database naam.
Nu kunnen we op een veilige manier een test toevoegen voor de POST method:
tests/test_auth.py
Helaas werkt het nog niet helemaal en krijgen we de volgende fout:
File "./dev/github/flaskr/flaskr/auth.py", line 35, in register
db.execute(
~~~~~~~~~~^
"INSERT INTO user (username, password) VALUES (?, ?)",
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
(username, generate_password_hash(password)),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
sqlite3.OperationalError: no such table: user
============================ short test summary info =============================
FAILED tests/test_auth.py::test_register_post - assert 500 == 302
=============================== 1 failed in 0.21s ================================
Wat is het probleem?
De database (in-memory!) is nooit geïnitialiseerd en heeft dus geen tables!
In de db module hebben we een functie die precies dat doet, init_db. We kunnen deze op dezelfde manier configureerbaar maken en gebruiken tijdens het opstellen van de test:
flaskr/db.py
tests/test_auth.py
Nog steeds krijgen we dezelfde fout: sqlite3.OperationalError: no such table: user! De reden is nu niet zo triviaal.
In-Memory SQLite Valkuil
De in-memory SQLite Connection die onze test gebruikt heeft een bijzonderheid. Als we in de Python SQLite documentatie snuisteren komen we uit op SQLite In Memory Databases, en daar lezen we dit:
Every :memory: database is distinct from every other. So, opening two database connections each with the filename “:memory:” will create two independent in-memory databases
En even verder:
In-memory databases are allowed to use shared cache if they are opened using a URI filename.
rc = sqlite3_open("file::memory:?cache=shared", &db);
Terug in de Python documentatie vinden we zelfs een voorbeeld: db = "file:mem1?mode=memory&cache=shared". We zien daar ook dat bij de sqlite3.connect functie het argument uri=True gebruikt moet worden. Dit moeten we dus ook aanpassen zowel in get_db als in init_db in onze db.py module.
flaskr/db.py
def get_db() -> sqlite3.Connection:
print(f"GET DB WITH {current_app.config['DATABASE']}")
if "db" not in g:
g.db = sqlite3.connect(current_app.config["DATABASE"], uri=True)
g.db.row_factory = sqlite3.Row
return g.db
def close_db(_exc=None):
if "db" in g:
g.db.close()
def init_db(database: str = SQLITE_DB_FILE):
print(f"INIT DB WITH {database}")
db = sqlite3.connect(database, uri=True)
with open(LOCAL_DIRECTORY / "schema.sql") as schema:
db.executescript(schema.read())
print("SQLite schema created. Tables in DB:")
result = db.execute("SELECT name FROM sqlite_master WHERE type='table'")
print(result.fetchall())We passen onze test opnieuw aan. We controleren ook direct of we wel worden doorgestuurd (redirect) naar de login pagina.
tests/test_auth.py
def test_register_post():
app = create_app("file:mem1?mode=memory&cache=shared")
db.init_db("file:mem1?mode=memory&cache=shared")
client = app.test_client()
response = client.post("/auth/register", data={"username": "a", "password": "a"})
assert response.status_code == 302
assert response.headers["Location"] == "/auth/login"De test werkt nu eindelijk!
User controleren in de database
Na een registratie zouden we ook graag controleren dat de nieuwe gebruiker ook daadwerkelijk is aangemaakt in de database. We voegen een extra assert toe op het einde van de test:
tests/test_auth.py
We krijgen echter een nieuwe fout …
assert (
> db.get_db().execute("SELECT * FROM user WHERE username = 'a'").fetchone()
^^^^^^^^^^^
is not None
)
tests/test_auth.py:23:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
flaskr/db.py:8: in get_db
if "db" not in g:
^^^^^^^^^^^^^
.venv/lib/python3.13/site-packages/werkzeug/local.py:318: in __get__
obj = instance._get_current_object()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
def _get_current_object() -> T:
try:
obj = local.get()
except LookupError:
> raise RuntimeError(unbound_message) from None
E RuntimeError: Working outside of application context.
E
E This typically means that you attempted to use functionality that needed
E the current application. To solve this, set up an application context
E with app.app_context(). See the documentation for more information.
.venv/lib/python3.13/site-packages/werkzeug/local.py:519: RuntimeError
Om deze fout beter te begrijpen blikken we even terug op Les 1 - De Application Context, ofwel op de Flask Application Context documentatie pagina.
get_db inspecteert het flask.g object. En dat object bestaat enkel binnen een application context. Verder lezen we ook dit:
Flask automatically pushes an application context when handling a request.
Maar op het moment dat we hier get_db uitvoeren zitten we niet ‘in een request’. Verder lezen we ook:
If you try to access current_app, or anything that uses it, outside an application context, you’ll get this error message:
RuntimeError: Working outside of application context. This typically means that you attempted to use functionality that needed to interface with the current application object in some way. To solve this, set up an application context with app.app_context().
Onze test wordt dus:
tests/test_auth.py
def test_register_post():
app = create_app("file:mem1?mode=memory&cache=shared")
db.init_db("file:mem1?mode=memory&cache=shared")
client = app.test_client()
response = client.post("/auth/register", data={"username": "a", "password": "a"})
assert response.status_code == 302
assert response.headers["Location"] == "/auth/login"
with app.app_context():
assert (
db.get_db().execute("SELECT * FROM user WHERE username = 'a'").fetchone()
is not None
)Pytest Fixtures
Een van de beste features in pytest is fixtures.
Met fixtures kunnen data of objecten die in verschillende testen gebruikt worden eenmalig gedefinieerd worden. Vervolgens kan elke test die een fixture nodig heeft deze verkrijgen door simpelweg de naam de de fixture functie in de argumenten van de test-functie te plaatsen.
Als we naar onze eerste twee testen kijken zien we dat ze beiden een test client (FlaskClient) nodig hebben om test requests te versturen. Een prima kandidaat voor een fixture dus.
tests/test_auth.py
1import pytest
from flask import Flask
from flask.testing import FlaskClient
from flaskr import create_app, db
2@pytest.fixture
def app():
database = "file:mem1?mode=memory&cache=shared"
app = create_app(database)
db.init_db(database)
return app
3@pytest.fixture
def client(app: Flask):
return app.test_client()
4def test_register_get(client: FlaskClient):
response = client.get("/auth/register")
assert response.status_code == 200
5def test_register_post(app: Flask, client: FlaskClient):
response = client.post("/auth/register", data={"username": "a", "password": "a"})
assert response.status_code == 302
assert response.headers["Location"] == "/auth/login"
with app.app_context():
assert (
db.get_db().execute("SELECT * FROM user WHERE username = 'a'").fetchone()
is not None
)- 1
-
We moeten
pytestimporteren om fixtures te gebruiken. Uitflaskimporteren we ook een paar classes om als type hints te gebruiken. - 2
-
Een eerste fixture maakt de test Flask applicatie aan en geeft het
appobject terug. We maken van de gelegenheid gebruik om alvast de test database te initialiseren. - 3
-
Fixtures kunnen zelf ook andere fixtures gebruiken! In dit geval gebruiken we de
appfixture om daarmee eenFlaskClientobject terug te geven. - 4
-
Die
clientfixture kan dan gebruikt worden in de twee bestaande, en toekomstige testen. - 5
-
Voor
test_register_posthebben we deappfixture ook nog even nodig, om toegang te krijgen tot deapp_contextmethode.
get_db zullen we hoogstwaarschijnlijk nog vaker willen gebruiken om in andere testen iets te controleren in de database. Het zou handig zijn als we altijd al in een app_context context manager zouden zitten. Dat kan op de volgende manier:
tests/test_auth.py
De with app.app_context() in de test_register_post test is nu niet meer nodig. De test heeft bijgevolg de app fixture ook niet meer nodig.
Een functie kan zowel met return als met yield een waarde teruggeven. Maar wat is dan het verschil?
returngeeft een waarde terug en beëindigt meteen de functie.yieldgeeft een waarde terug, maar pauzeert de functie zodat die later hervat kan worden.
yield wordt vooral gebruikt bij Generators. Maar dus ook bij test fixtures zoals hierboven, waarbij we de context manager pas kunnen verlaten als het app object niet meer nodig is, m.a.w. na de test.
Meer informatie vind je in de Pytest documentatie.
De auth testen verder uitwerken
Registratie: unhappy path
Voor het registratieproces hebben we nu een test voor het zogenaamde happy path. Maar wat als de gegevens in de registratie POST request niet correct zijn?
Bijvoorbeeld als zowel username als password leeg zijn…
tests/test_auth.py
Of als enkel het password leeg is …
tests/test_auth.py
Of als de username reeds bestaat…
tests/test_auth.py
def test_register_existing_user(client):
conn = db.get_db()
conn.execute(
"INSERT INTO user (username, password) VALUES "
"('test', 'scrypt:32768:8:1$B6EWUB7sblZHpKwE$74951791e0ebcdcf91999e0e4c3e7768fcb87f994c35de0d80e99d83ec36e9f542b76c4d486c57ced5cea72fd76c3f5a64b0a2c31a89a02e3a86a52a6f52fb1c')"
)
conn.commit()
response = client.post("/auth/register", data={"username": "test", "password": "b"})
assert b"already registered" in response.dataPytest Parametrize
Naast fixtures is parametrize een van de interessantste Pytest features.
De laatste drie testen houden allemaal verband met elkaar en verschillen slechts in een aantal parameters:
- De gebruikte
usernameenpassword - Het verwachten bericht in de
response.data
Met de pytest.mark.parametrize decorator kunnen we de drie testen herleiden tot een enkele:
tests/test_auth.py
@pytest.mark.parametrize(
1 ("username", "password", "message"),
2 (
("", "", b"Username is required."),
("test", "", b"Password is required."),
("test", "test", b"already registered"),
),
)
3def test_register_validate_input(client, username, password, message):
conn = db.get_db()
conn.execute(
"INSERT INTO user (username, password) VALUES "
"('test', 'scrypt:32768:8:1$B6EWUB7sblZHpKwE$74951791e0ebcdcf91999e0e4c3e7768fcb87f994c35de0d80e99d83ec36e9f542b76c4d486c57ced5cea72fd76c3f5a64b0a2c31a89a02e3a86a52a6f52fb1c')"
)
conn.commit()
response = client.post(
"/auth/register", data={"username": username, "password": password}
)
assert message in response.data- 1
- Het eerste argument van de decorator is een tuple met de namen (keys) van de verschillende parameters.
- 2
- Het tweede argument is een tuple van tuples, met de waarden (values) die voor de parameters gebruikt zullen worden.
- 3
- De namen van de parameters worden meegegeven aan de test functie (en wel na eventuele fixtures). De test functie zal dan uitgevoerd worden voor elke combinatie parameters.
Login
Probeer als oefening zelf de testen te schrijven voor de login route. Je kan dit op dezelfde manier aanpakken als de register testen die we reeds hebben.
Wat willen we allemaal testen?
- Een bestaande gebruiker kan inloggen
- GET + POST
- Doorgestuurd naar de index (
/) - Bij een volgende
GET /bevat de session cookie deuser_id
- Een bestaande gebruiker kan niet inloggen, en krijgt de juiste foutmelding, wanneer de username of password foutief zijn.
Antwoord
tests/test_auth.py
from flask import Flask, session, g
def test_login(client):
conn = db.get_db()
conn.execute(
"INSERT INTO user (username, password) VALUES "
"('test', 'scrypt:32768:8:1$B6EWUB7sblZHpKwE$74951791e0ebcdcf91999e0e4c3e7768fcb87f994c35de0d80e99d83ec36e9f542b76c4d486c57ced5cea72fd76c3f5a64b0a2c31a89a02e3a86a52a6f52fb1c')"
)
conn.commit()
assert client.get("/auth/login").status_code == 200
response = client.post("/auth/login", data={"username": "test", "password": "test"})
assert response.headers["Location"] == "/"
with client:
client.get("/")
assert session["user_id"] == 1
assert g.user["username"] == "test"tests/test_auth.py
@pytest.mark.parametrize(
("username", "password", "message"),
(
("a", "test", "Incorrect username."),
("test", "a", "Incorrect password."),
),
)
def test_login_validate_input(client, username, password, message):
conn = db.get_db()
conn.execute(
"INSERT INTO user (username, password) VALUES "
"('test', 'scrypt:32768:8:1$B6EWUB7sblZHpKwE$74951791e0ebcdcf91999e0e4c3e7768fcb87f994c35de0d80e99d83ec36e9f542b76c4d486c57ced5cea72fd76c3f5a64b0a2c31a89a02e3a86a52a6f52fb1c')"
)
conn.commit()
response = client.post(
"/auth/login", data={"username": username, "password": password}
)
assert message in response.textHet toevoegen van een test user wordt verschillende keren herhaald. Op welke manieren kunnen dit verhelpen?
Test Coverage
Met onze bestaande testen wordt al heel wat code van onze applicatie getest. We hebben niet zo veel code en testen, dus het is hier vrij triviaal om te bepalen wat nog niet getest wordt. In een grotere codebase met honderden testen wordt dat al wat moeilijker.
Met het coverage package krijgen we een exact beeld van de code die wel en niet uitgevoerd wordt door onze testen!
Dan gebruiken we coverage om met pytest de testen opnieuw uit te voeren:
$ coverage run -m pytest tests/
====================== test session starts =======================
platform darwin -- Python 3.13.5, pytest-8.4.2, pluggy-1.6.0
rootdir: /Users/dfabrice/dev/github/flaskr
configfile: pyproject.toml
collected 8 items
tests/test_auth.py ........ [100%]
======================= 8 passed in 0.36s ========================Op het eerste zicht is er niets veranderd, maar er is een .coverage bestand aangemaakt. Dit is een database (SQLite!) waarin coverage alle resultaten bijhoudt. We voegen het best ook toe aan de .gitignore zodat het niet gecommit wordt.
Hieruit kan dan een rapport opgevraagd worden:
$ coverage report -m
Name Stmts Miss Cover Missing
--------------------------------------------------
flaskr/__init__.py 18 2 89% 17, 25
flaskr/auth.py 60 5 92% 90-91, 97-100
flaskr/blog.py 58 38 34% 24-43, 47-64, 70-90, 96-100
flaskr/db.py 25 2 92% 40-41
tests/test_auth.py 41 0 100%
--------------------------------------------------
TOTAL 202 47 77%De -m (missing) zorgt er voor dat we een overzicht krijgen van de ongeteste blokken code. Met coverage html krijgen we een interactieve HTML pagina die heet nog eenvoudiger maakt de resultaten te interpreteren.
Uit onze auth module testen we al 92% van de code! Bekijk welke code niet getest is en verklaar waarom dit zo is.
Antwoord
- Voor de
logoutroute hebben simpelweg nog geen test geschreven. - De
login_requireddecorator wordt in geen enkele van deauthroutes zelf gebruikt. Als we later deblogroutes testen komt daar automatisch verandering in.
De blog module testen
Als we over testen voor de blog module nadenken wordt het al snel duidelijk dat we dezelfde app en client fixtures zullen nodig hebben. Pytest voorziet een handige optie om duplicatie te vermijden: fixtures in een speciaal conftest.py bestand zijn automatisch beschikbaar in alle testen uit dezelfde en onderliggende folders.
We verplaatsen dus eerst de twee fixtures (met bijhorende imports) naar tests/conftest.py en bevestigen dat de bestaande testen nog werken.
Een test blogpost en de index
Om te starten testen we de index (startpagina) en willen bevestigen dat we op deze pagina links hebben om in te loggen of te registreren en test post kunnen zien.
De test database die voor elke test wordt aangemaakt bevat wel al een test user, maar geen post. Voor deze eerste test hebben we al direct een test post nodig en voor latere testen misschien ook. Dus we breiden de app fixture uit om ook een test post in de database toe te voegen.
tests/conftest.py
import pytest
from flask import Flask
from flaskr import create_app, db
@pytest.fixture
def app():
database = "file:mem1?mode=memory&cache=shared"
app = create_app(database)
db.init_db(database)
with app.app_context():
conn = db.get_db()
conn.execute(
"INSERT INTO user (username, password) VALUES "
"('test', 'scrypt:32768:8:1$B6EWUB7sblZHpKwE$74951791e0ebcdcf91999e0e4c3e7768fcb87f994c35de0d80e99d83ec36e9f542b76c4d486c57ced5cea72fd76c3f5a64b0a2c31a89a02e3a86a52a6f52fb1c')"
)
conn.execute(
"INSERT INTO user (username, password) VALUES "
"('other', 'scrypt:32768:8:1$6DDdh5peSL3fmCBy$c997bb4e0ecdeb5ad2e94cd4cde3a58efa290889a7c40497b154cd23df46ffc25f731370f849ab262fd310d9ee48ce5a9950a6910e08b9a8f52ec791acb85c61')"
)
conn.execute(
"INSERT INTO post (title, body, author_id, created) VALUES"
"('test title', 'test' || x'0a' || 'body', 1, '2025-01-01 00:00:00')"
)
conn.commit()
yield app
@pytest.fixture
def client(app: Flask):
return app.test_client()Een eenvoudige test voor de / route kunnen we dan als volgt schrijven:
tests/test_blog.py
import pytest
from flask.testing import FlaskClient
def test_index(client: FlaskClient):
response = client.get("/")
assert b"Log In" in response.data
assert b"Register" in response.data
assert b"test title" in response.data
assert b"by test on 2025-01-01" in response.data
assert b"test\nbody" in response.datalogin_required
De login_required decorator moet er voor zorgen dat enkel ingelogde gebruikers posts kunnen aanmaken, updated of verwijderen. Op welke manier kunnen we functioneel gaat testen dat een niet-ingelogde gebruiker deze mogelijkheden niet heeft?
Probeer zelf de test te schrijven. Tip: de test ziet er precies hetzelfde uit voor elke route die we testen - je kan dus opnieuw gebruik maken van pytest.mark.parametrize.
Blogpost auteur validatie
- Een ingelogde user kan geen blogpost updated of verwijderen als deze niet door hem is aangemaakt.
- Een blogpost die niet bestaat updaten of verwijderen wordt beantwoord met een HTTP 404 status code.
Oplossing
tests/test_blog.py
def test_author_required(client: FlaskClient):
client.post("/auth/login", data={"username": "other", "password": "other"})
# current user can't modify other user's post
assert client.post("/1/update").status_code == 403
assert client.post("/1/delete").status_code == 403
# current user doesn't see edit link
assert b'href="/1/update"' not in client.get("/").data
@pytest.mark.parametrize(
"path",
(
"/2/update",
"/2/delete",
),
)
def test_post_not_exists(client: FlaskClient, path):
client.post("/auth/login", data={"username": "test", "password": "test"})
assert client.post(path).status_code == 404Blogpost maken en updaten
- Een
GETrequest van een ingelogde user voor zowel de create als update views wordt beantwoord met een HTTP 200 status code. - Wanneer een ingelogde user geldige data
POSTnaar de create view wordt een nieuwe post in de database weggeschreven. - Wanneer een ingelogde user geldige data
POSTnaar de update view wordt de bestaande post in de database geupdate. - Wanneer een ingelogde user ongeldige data (lege
title)POSTnaar beide views wordt een foutmelding teruggestuurd en is er geen update in de database
Oplossing
tests/test_blog.py
def test_create(client: FlaskClient):
client.post("/auth/login", data={"username": "test", "password": "test"})
assert client.get("/create").status_code == 200
client.post(
"/create", data={"title": "test_create title", "body": "test_create body"}
)
conn = db.get_db()
body = conn.execute(
"SELECT body FROM post WHERE title = 'test_create title'"
).fetchone()[0]
assert body == "test_create body"
def test_update(client: FlaskClient):
client.post("/auth/login", data={"username": "test", "password": "test"})
assert client.get("/1/update").status_code == 200
client.post("/1/update", data={"title": "updated title", "body": "updated body"})
conn= db.get_db()
post = conn.execute("SELECT * FROM post WHERE id = 1").fetchone()
assert post["title"] == "updated title"
assert post["body"] == "updated body"
@pytest.mark.parametrize(
"path",
(
"/create",
"/1/update",
),
)
def test_create_update_validate(client: FlaskClient, path: str):
client.post("/auth/login", data={"username": "test", "password": "test"})
response = client.post(path, data={"title": "", "body": "invalid post"})
assert b"Title is required." in response.data
conn = db.get_db()
post = conn.execute("SELECT * FROM post WHERE body = 'invalid post'").fetchone()
assert post is NoneDelete
- Een ingelogde user kan een van zijn eigen blogposts verwijderen en wordt dan teruggestuurd naar de index. De blogpost is dan verwijderd uit de database.
Oplossing
tests/test_blog.py
Extras
Lees/kijk-voer en referenties
Referentie Implementatie
Een volledig werkende implementatie gebaseerd op deze cursus vind je op https://github.com/dvx76/flask-tutorial.
