Flask ja templatet
Näissä tehtävissä tutustutaan html-koodin tuottamiseen Flaskilla
users.jyu.fi-palvelimella. Lisäksi opitaan, mitä tarkoittaa
HTTP-protokollan
tilattomuus.
Tehtävän apuna kannattaa käyttää seuraavia dokumentteja:
Autolaskuri
Toteutetaan autolaskuri Flask-ohjelmana.
-
Luo omalle koneellesi seuraavanlainen Flask-ohjelma:
from flask import Flask, request app = Flask(__name__) laskuri = 0 @app.route("/") def autolaskuri(): global laskuri return f"""<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" lang="fi" xml:lang="fi"> <head> <meta charset="UTF-8" /> <title>Autolaskuri</title> </head> <body> <h1>Autolaskuri</h1> <div>{laskuri}</div> <form action="" method="get" accept-charset="UTF-8"> <p><input type="submit" value="Lisää auto" /></p> </form> </body> </html>"""Merkkijono on f-string, jolloin
{laskuri}-kohtaan sijoittuu muuttujan arvo. Käynnistä sovellus komennollaflask run --debug. - Lomake käyttää oletuksena samaa merkistöä kuin sivu. Merkistön voi silti
varmistaa
form-elementinaccept-charset-attribuutilla. Käytä ainaUTF-8-merkistöä. - Kokeile lomakkeen painiketta. Sivu latautuu uudelleen eli selain lähettää lomakkeen tiedot palvelimelle. Vielä lomaketta ei huomioida ohjelmassa mitenkään.
-
Lisää painikkeelle
name-attribuutti:<p><input type="submit" name="painike" value="Lisää auto" /></p>Lataa sivu uudelleen ja kokeile painiketta. Mikä muuttuu? Tarkastele osoiteriviä.
-
Lomakkeen metodi on
GET, joten kaikki lomakkeen tiedot koodataan sivun osoitteeseen. Lisää lomakkeeseen vielä yksi kenttä:<p><label for="lkm">Lisättävä määrä</label> <input id="lkm" type="text" value="1" name="lkm" /></p>Kokeile painiketta. Lomakkeen kentät lisätään osoitteen perään
?-merkin jälkeen ja erotellaan&-merkillä. Kyseessä on edellisviikolla käsitelty querystring, joka on selitetty myös HTTP-luennossa. - Kokeile muuttaa
lkm-kentän tyypiksinumber. Muuttuvatko parametrit? Mitään ei pitäisi muuttua: kenttien tyyppejä ei välitetä palvelimelle, ainoastaan arvot, ja kaikki tieto tulee merkkijonoina. -
Lisää laskurimuuttujaan
lkm-kentän arvo. Muunna se ensin kokonaisluvuksi.try: lkm = int(request.args.get("lkm", 0)) except ValueError: lkm = 0 laskuri = laskuri + lkmNappaa vain se poikkeus, jonka odotat. Paljas
except:piilottaisi myös omat ohjelmointivirheesi. Vaihtoehtoisesti sama onnistuu ilmantry-lohkoa Werkzeugintype-parametrilla:request.args.get("lkm", 0, type=int).
Kentässä voi olla mitä tahansa: tyhjää, kirjaimia, miinusmerkkinen luku tai tuhat merkkiä pitkä merkkijono. Osoitteeseen voi myös kirjoittaa parametrit käsin ilman lomaketta. Tarkista arvot aina itse.
- Kokeile selaimella, toimiiko laskuri. Se vaikuttaisi toimivan. Sammuta kehityspalvelin (CTRL-C) ja käynnistä se uudelleen. Mitä laskuri näyttää nyt? Laskuri nollautuu aina Python-tulkin käynnistyessä.
- Käynnistä kaksi eri selainta ja käytä laskuria molemmilla samaan aikaan. Onko eri käyttäjillä eri vai sama laskuri? Miksi näin? Miten toteuttaisit jokaiselle oman laskurin? Globaali muuttuja ei kelpaa ratkaisuksi.
-
Kokeile samaa ohjelmaa
users.jyu.fi-palvelimella. Katso edellisestä tehtävästä, miten Flask-sovellus saadaan toimimaan users.jyu.fi-palvelimella.Laskuri ei toimi siellä ollenkaan, koska CGI-ohjelmassa käynnistetään uusi Python-tulkki jokaisella suorituskerralla.
Globaali muuttuja on prosessikohtainen. CGI-ympäristössä se nollautuu joka pyynnöllä, WSGI-palvelimella se säilyy vain siihen asti kunnes prosessi käynnistetään uudelleen, ja pilvipalvelussa jokaisella instanssilla on omansa. Sama koskee tiedostoon kirjoittamista: sekään ei ratkaise ongelmaa, koska tila olisi silloin yhteinen kaikille käyttäjille.
-
Vain lomakkeelta lähetetyt tiedot voivat vaikuttaa laskurin tilaan, koska HTTP-protokolla on tilaton. Tilan tallentamiseen on useita tapoja, joihin palataan kurssin tulevilla viikoilla. Nyt tila tallennetaan lomakkeen piilokenttään:
<input type="hidden" value="{laskuri}" name="laskuri" />Muuta globaali laskurimuuttuja paikalliseksi ja lue sen arvo aina piilokentästä. Jos arvoa ei ole tai se on kelvoton, käytä alkuarvona nollaa.
@app.route("/") def autolaskuri(): laskuri = request.args.get("laskuri", 0, type=int) lkm = request.args.get("lkm", 0, type=int) laskuri = laskuri + lkm ... - Kokeile lomaketta. Laskurin arvon pitäisi nyt näkyä sivun osoitteessa.
- Parantele lomaketta siten, että se muistaa myös edellisen lisättävän määrän.
-
Kokeile muuttaa lomakkeen metodiksi
POST. Silloinrequest.argsvaihtuurequest.form-rakenteeseen, ja reitille on lisättävä POST-metodi, koska Flask käsittelee oletuksena vain GET-pyynnöt:@app.route("/", methods=["GET", "POST"])Muuttuuko lomakkeen toiminta? GET-metodia käytetään haettaessa tietoja ilman muutoksia, POST-metodia silloin kun tietoja lisätään, muutetaan tai poistetaan. Huomaa, ettei POST ole turvallisuusominaisuus: tiedot eivät näy osoiterivillä, mutta ne näkyvät sellaisenaan kehittäjätyökaluissa.
-
Paina POST-lomakkeen jälkeen selaimen päivityspainiketta. Selain kysyy, lähetetäänkö lomake uudelleen, ja auto lisätään toistamiseen. Korjaa tämä POST/Redirect/GET-kuviolla: käsittele lomake POST-pyynnössä ja vastaa uudelleenohjauksella, jonka selain hakee GET-pyynnöllä.
from flask import redirect, url_for @app.post("/") def lisaa(): laskuri = request.form.get("laskuri", 0, type=int) lkm = request.form.get("lkm", 0, type=int) return redirect(url_for("autolaskuri", laskuri=laskuri + lkm), code=303)Kokeile nyt päivittämistä uudelleen. Lisää halutessasi käyttäjälle ilmoitus flash-viestillä.
Malliratkaisu:
- Toimiva sovellus
- Lähdekoodi v1 — globaali muuttuja
- Lähdekoodi v2 — ei globaalia muuttujaa
- Lähdekoodi v3 — POST-metodi
Templatet
Ohjelmakoodin ja html-koodin kirjoittaminen sekaisin tuottaa vaikeasti ylläpidettävää koodia ja kasvattaa virheiden määrää. Otetaan käyttöön Jinja-template, jolla html-koodi siirtyy omaan tiedostoonsa.
Jinja-templateen voi kirjoittaa ohjausrakenteita, ja syntaksi muistuttaa Pythonia mutta ei ole sitä. Varmista syntaksi aina Jinjan dokumentaatiosta.
- Templateja etsitään
templates-alikansiosta, joka sijaitsee samassa kansiossa kuin sovellus. Templateissa ovat käytettävissärender_template-kutsun parametrit sekärequest-,session- jag-objektit. - Luo
templates-kansioon tiedostojinja.htmlja siirrä sen sisällöksi Python-koodissa oleva html. - Poista Python-ohjelmasta kaikki html-koodia sisältävät rivit.
- Korvaa templatessa f-stringin
{laskuri}-merkinnät merkinnällä{{ laskuri }}. Tee samoinlkm-muuttujalle. -
Kokeile, toimiiko ohjelmasi, kun vaihdat funktion viimeisen rivin muotoon:
return render_template("jinja.html", laskuri=laskuri, lkm=lkm) - Tutustu Jinjan dokumentaatioon ja luentosivun Jinja-osuuteen.
-
Lisätään laskuriin automerkin valitseminen. Luo Python-tiedostossa sanakirja automerkeistä ja vie se templatelle:
automerkit = {"1": "Tesla", "2": "Lada", "3": "Mini"} return render_template("jinja.html", laskuri=laskuri, lkm=lkm, automerkit=automerkit) -
Lisää
jinja.html-tiedostoon lomakkeelle seuraava koodi:<p> <label for="automerkki">Automerkki</label> <select id="automerkki" name="automerkki"> {% for tunnus, nimi in automerkit.items() %} <option value="{{ tunnus }}">{{ nimi }}</option> {% endfor %} </select> </p>Jinjassa ohjausrakenteet erotetaan
{% %}-merkinnällä ja yksittäisten muuttujien arvot tulostetaan{{ }}-merkinnällä. Silmukka käy läpi automerkit ja muodostaa niistäselect-elementin sisällön. Kokeile, miten sovellus toimii. - Muuta automerkkien tietorakennetta siten, että samaan rakenteeseen voi tallentaa myös tiedon, kuinka monta kertaa kutakin merkkiä on laskettu. Tulosta kaikkien automerkkien laskurit nykyisen yhden laskurin tilalle ja varmista, että lomake toimii uudella rakenteella.
-
Korjaa laskuri lisäämään määrä vain valitulle automerkille. Nyt lomakkeelle on tallennettava koko tietorakenne. Käytä json-kirjastoa:
import json # tietorakenne lomakkeelle piilokenttään, mahdollisimman tiiviissä muodossa piilokentta = json.dumps(automerkit, separators=(",", ":")) # lomakkeelta takaisin tietorakenteeksi try: automerkit = json.loads(request.args.get("automerkit", "")) except json.JSONDecodeError: automerkit = oletusautomerkit()Muista varautua siihen, ettei lomakkeelta tulekaan kelvollista JSON-dataa. JSONin käyttäminen on käsitelty ensimmäisessä pääteohjauksessa.
-
Laita lomake muistamaan, mikä automerkki oli valittuna. Vie valinta parametrina templatelle ja testaa se silmukan sisällä:
{% for tunnus, nimi in automerkit.items() %} <option value="{{ tunnus }}"{% if tunnus == valittu %} selected="selected"{% endif %}> {{ nimi }} </option> {% endfor %}Huomaa, että lomakkeelta tulevat arvot ovat aina merkkijonoja. Jos tietorakenteen avaimet ovat kokonaislukuja, vertailu ei osu.
Malliratkaisu:
Jinjan templatet ja perintä
Pohjatemplate määrittää kaikille sivuille yhteisen rungon. Lue Template Inheritance ja kokeile seuraavia templateja.
layout.html toimii kaikkien sivujen yleispohjana:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="fi" xml:lang="fi">
<head>
<meta charset="UTF-8" />
<title>{% block otsikko %}{% endblock %} - Mallisivu</title>
</head>
<body>
<div id="sisalto">{% block sisalto %}{% endblock %}</div>
<div id="alatunniste">
{% block alatunniste %}TIES4080{% endblock %}
</div>
</body>
</html>
Muuta jinja.html käyttämään pohjaa:
{% extends "layout.html" %}
{% block otsikko %}Laskuri{% endblock %}
{% block sisalto %}
Tähän väliin pääsisältö
{% endblock %}
Kokeile, miltä sivu näyttää. Huomaa, että {% extends %} on oltava
templaten ensimmäinen rivi.
Millä varmistetaan HTML-rakenteen validius ja ehjyys?
Jinja ei estä kirjoittamasta rikkinäistä ja epävalidia html-koodia. On olemassa XML-pohjaisia template-kirjastoja, ja Jinjan sijasta voisi käyttää myös DOM-rajapintaa esimerkiksi minidom-kirjaston avulla, mutta se on raskaampi ja hitaampi. Jinja ei ole paras ratkaisu XML-datan tuottamiseen, mutta sillä pärjää, koska html-muodossa jaetut sivut kelpaavat selaimille virheellisinäkin. Lue HOWTO Avoid Being Called a Bozo When Producing XML.
Kehitysvaiheessa apuna voi käyttää XHTML-mediatyyppiä ja selaimessa suoritettavaa validointia. Näitä ei kannata pitää päällä tuotantokäytössä olevassa sovelluksessa; tämän kurssin tehtävissä ne voivat olla aina käytössä.
HTML5 ja application/xhtml+xml
HTML5-kielestä on olemassa myös
XML-kieliopin
mukainen versio, jota kutsutaan usein nimellä XHTML5. Riittää, että kirjoitat
html-koodin XML:n sääntöjen mukaan ja jaat dokumentin
application/xhtml+xml-mediatyypillä. Silloin selain ei hyväksy koodia,
joka ei ole XML-sääntöjen mukaista.
Kokeile: kirjoita users.jyu.fi-palvelimelle dokumentti, jonka
tiedostopääte on .xhtml, ja lataa se selaimella. Tee dokumenttiin
kirjoitusvirhe, esimerkiksi unohda jonkin elementin lopetustagi. Selaimen pitäisi
näyttää virheilmoitus:
XML Parsing Error: mismatched tag. Expected: </p>.
Location: https://foobar.example/sivu.xhtml
Line Number 45, Column 3:
</body>
--^
XHTML-dokumentissa nimiavaruusmäärittelyn on oltava oikein. Käytä seuraavaa pohjaa:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="fi" xml:lang="fi">
<head>
<meta charset="UTF-8" />
<title>Mallipohja</title>
</head>
<body>
</body>
</html>
Kokeile, miten pohja toimii ilman nimiavaruusmäärittelyä.
Flask käyttää oletuksena text/html-mediatyyppiä. Yksittäisen
vastauksen tyypin voi vaihtaa näin:
from flask import Response, make_response, render_template
vastaus = make_response(render_template("jinja.html", laskuri=laskuri))
vastaus.headers["Content-Type"] = "application/xhtml+xml; charset=UTF-8"
return vastaus
tai suoraan Response-oliota luotaessa:
return Response(render_template("jinja.html", laskuri=laskuri),
content_type="application/xhtml+xml; charset=UTF-8")
Kun jokainen näkymä tarvitsee saman tyypin, se kannattaa asettaa yhdessä
paikassa
after_request-koukussa:
@app.after_request
def xhtml_tyyppi(vastaus):
if vastaus.mimetype == "text/html":
vastaus.headers["Content-Type"] = "application/xhtml+xml; charset=UTF-8"
return vastaus
Käytä content_type-parametria äläkä mimetype-parametria,
kun haluat merkistön mukaan: mimetype ottaa vain paljaan tyypin, eikä
Werkzeug lisää merkistöä muille kuin text/-alkuisille tyypeille.
Kokeile, toimiiko aiemmin luomasi sovellus
application/xhtml+xml-mediatyypillä, ja korjaa mahdolliset virheet. Jos
saat näkyviin pelkän sivun lähdekoodin, templatesta puuttuu
nimiavaruusmäärittely.
Automaattinen validointi
Selain ei validoi XHTML-dokumenttia vaan tarkistaa ainoastaan, että sen rakenne on XML-sääntöjen mukainen. Rakenteen ehjyys ei siis vielä tarkoita, että dokumentti olisi validia HTML:ää — esimerkiksi väärässä paikassa oleva elementti tai puuttuva pakollinen attribuutti menee XML-jäsentimestä läpi.
Validoi sovelluksesi tuottama sivu W3C-validaattorilla. Koska sovelluksen osoite ei ole julkinen kehitysvaiheessa, käytä validaattorin Validate by Direct Input -välilehteä ja liitä siihen sivun lähdekoodi.
Työn voi automatisoida: valmiissa pohjadokumentissa on javascriptillä toteutettu validointi, joka suoritetaan sivun latauduttua, sekä pikanäppäin W3C:n validaattoriin. Virheet näkyvät selaimen konsolissa.
Pohjan käyttämä HTML-inspector on vuosia ylläpitämätön kirjasto, eikä se tunne uusimpia HTML-piirteitä. Jos se antaa virheilmoituksia, joita W3C:n validaattori ei anna, luota validaattoriin. Muista myös, ettei validointiskriptejä jätetä tuotantoon.
Linkki vai lomake?
- Muuta lomakkeesi käyttämään GET-metodia. Muista, että
request.valuessisältää sekä osoitteen että lomakkeen rungon parametrit. - Yritä lisätä laskurilla eri automerkkejä ja tutki, miltä sivun osoite näyttää.
-
Lisää sivulle painikkeen alapuolelle jokaista automerkkiä varten linkki, jolla kyseisen merkin laskuria voi kasvattaa yhdellä. Älä kopioi linkkejä selaimesta vaan muodosta ne ohjelmallisesti.
Flaskissa osoitteet muodostetaan
url_for-funktiolla. Se ottaa ensimmäisenä parametrina funktion nimen, ei osoitetta, lisää loput parametrit querystringiin ja hoitaa prosenttikoodauksen. Se myös tuottaa oikean osoitteen silloin, kun sovellus ei ole palvelimen juuressa — kutenusers.jyu.fi-palvelimellaflask.cgi-tiedoston alla.from flask import url_for # valmistellaan linkit pythonissa, ei templatessa linkit = [] for tunnus, tiedot in automerkit.items(): linkit.append({ "nimi": tiedot["nimi"], "osoite": url_for("autolaskuri", automerkki=tunnus, lkm=1, automerkit=json.dumps(automerkit, separators=(",", ":"))), })<ul> {% for linkki in linkit %} <li><a href="{{ linkki.osoite }}">{{ linkki.nimi }}</a></li> {% endfor %} </ul>Minimoi templatessa olevan koodin määrä. Valmistele kaikki rakenteet Pythonissa mahdollisimman pitkälle.
-
Osoitteen voi muodostaa myös suoraan urlencode-funktiolla, jos sovelluskehys ei ole käytettävissä. Huomaa, että
parse-alimoduuli on tuotava erikseen:>>> from urllib.parse import urlencode, quote_plus >>> urlencode({"lkm": 1, "automerkki": "1"}) 'lkm=1&automerkki=1' >>> quote_plus("Ällö merkki") '%C3%84ll%C3%B6+merkki'Pelkkä
import urllibei riitä, vaan tarvitaanimport urllib.parse. - Validoi lopuksi sovelluksesi tuottama sivu. Validaattori kertoo, jos linkeissä
on koodaamattomia merkkejä. Muista, että osoitteessa tarvitaan
prosenttikoodaus ja html-dokumentissa lisäksi
&-merkkien koodaaminen entiteeteiksi — jälkimmäisen hoitaa Jinjan autoescape automaattisesti.
Malliratkaisu:
Flask-WTF
Flask-WTF on Flaskiin integroitu versio WTForms-kirjastosta, joka helpottaa lomakkeiden käsittelyä ja validointia.
Käy läpi Flask-WTF-quickstart ja vilkaise tarvittaessa WTForms crash course.

Toteuta kuvaa vastaava arvosanalaskuri Flask-WTF-kirjastolla.
WTForms renderöi kentät HTML5-tyyliin ilman sulkevaa kauttaviivaa
(<input type="text">), mikä ei kelpaa XML-jäsentimelle.
Ratkaisuja on kaksi:
- Asenna virtuaaliympäristöön
Flask-WTF-Polyglot
ja peri lomakeluokka
PolyglotForm-luokasta. Kirjasto on vanha, joten testaa ensin, että se toimii nykyisen WTFormsin kanssa. - Renderöi kentät itse Jinja-makrolla, jolloin merkkaus on täysin omissa käsissäsi. Tämä toimii aina.
pip install Flask-WTF-Polyglot
Oma makro näyttää esimerkiksi tältä:
{% macro tekstikentta(kentta, koko=20) %}
<p>
{{ kentta.label }}
<input type="text" id="{{ kentta.id }}" name="{{ kentta.name }}"
size="{{ koko }}" value="{{ kentta.data if kentta.data is not none else '' }}" />
{% for virhe in kentta.errors %}
<span class="virhe">{{ virhe }}</span>
{% endfor %}
</p>
{% endmacro %}
-
Luo lomakeluokka ja näkymäfunktio. Lomakkeen kentät ovat luokan attribuutteja, ja kentän otsikko annetaan ensimmäisenä parametrina:
from flask_wtf import FlaskForm from wtforms import StringField, IntegerField, SelectField, SelectMultipleField from wtforms import validators, ValidationError class Arvosanalaskuri(FlaskForm): etunimi = StringField("Etunimi") sukunimi = StringField("Sukunimi") @app.route("/wtlomake", methods=["GET", "POST"]) def wtlomake(): # csrf pois päältä väliaikaisesti, jotta tehtävää on helpompi testata form = Arvosanalaskuri(meta={"csrf": False}) return render_template("wtlomake.html", form=form)Vanhoissa ohjeissa neuvotaan
csrf_enabled=False. Se ei enää toimi: parametri poistettiin Flask-WTF:stä, se valuu huomiotta jääviin avainsanaparametreihin, ja suojaus jää päälle. Käytämeta={"csrf": False}-muotoa tai asetustaapp.config["WTF_CSRF_ENABLED"] = False. -
Luo template
wtlomake.html:<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" lang="fi" xml:lang="fi"> <head> <meta charset="UTF-8" /> <title>Arvosanalaskuri</title> </head> <body> <h1>Arvosanalaskuri</h1> <form action="{{ url_for('wtlomake') }}" method="post" accept-charset="UTF-8"> {{ tekstikentta(form.etunimi) }} {{ tekstikentta(form.sukunimi) }} <p><input type="submit" name="laheta" value="Lähetä" /></p> </form> </body> </html>Kenttiin voi viitata attribuuttien nimillä (
form.etunimi) tai taulukkosyntaksilla (form["etunimi"]), ja otsikko löytyy kentänlabel-ominaisuutena. Huomaa, että lomakkeen osoite muodostetaanurl_for-funktiolla. -
Lisää kenttiin validaattorit:
etunimi = StringField("Etunimi", validators=[validators.InputRequired()]) sukunimi = StringField("Sukunimi", validators=[validators.InputRequired()])Kokeile lomaketta. WTForms lisää kentille automaattisesti
required-attribuutin, joten selain estää lähettämisen. Ota selaimen oma validointi pois päältä lisäämälläform-elementillenovalidate-attribuutti:<form action="{{ url_for('wtlomake') }}" method="post" novalidate="novalidate">Mieti samalla, miksi pelkkään selaimen validointiin ei voi luottaa. Kehittäjätyökaluilla lomakkeen rakennetta voi muokata vapaasti, ja pyynnön voi lähettää ilman selainta.
-
Lisää toinen validaattori, joka määrää kentän pituuden:
etunimi = StringField("Etunimi", validators=[ validators.InputRequired(), validators.Length(min=2, message="Liian lyhyt etunimi"), ])Virheilmoitusta ei vielä näytetä, koska lomaketta ei validoida. Lisää validointi näkymäfunktioon:
if form.validate_on_submit(): # lomake on lähetetty POST-metodilla ja kaikki kentät kelpaavat return redirect(url_for("valmis"), code=303)Virheet näytetään templatessa. Edellä esitelty makro tulostaa ne jo valmiiksi; ilman makroa saman voi tehdä näin:
{% for virhe in form.etunimi.errors %} <span class="virhe">{{ virhe }}</span> {% endfor %}Kokeile, saatko virheilmoituksen yhden merkin etunimellä. Lisää sama validointi sukunimelle.
-
Lisätään tiedekunnan valinta. Luo lista avain-arvo-pareista luokan ulkopuolelle ja uusi kenttä luokkaan:
TIEDEKUNNAT = [ (0, "Valitse tiedekunta"), (1, "Humanistis-yhteiskuntatieteellinen tiedekunta"), (2, "Informaatioteknologian tiedekunta"), (3, "Kasvatustieteiden ja psykologian tiedekunta"), (4, "Liikuntatieteellinen tiedekunta"), (5, "Matemaattis-luonnontieteellinen tiedekunta"), (6, "Kauppakorkeakoulu"), ] tiedekunta = SelectField("Tiedekunta", choices=TIEDEKUNNAT)Lisää kenttä myös templateen virheilmoituksineen ja kokeile.
-
Saat todennäköisesti virheilmoituksen
Not a valid choice. Se johtuu siitä, että valintalistasta tulevat arvot ovat merkkijonoja, kun taas listan avaimet ovat kokonaislukuja. Ongelma korjaantuucoerce-parametrilla, ja oletusvalinnan saadefault-parametrilla:tiedekunta = SelectField("Tiedekunta", choices=TIEDEKUNNAT, coerce=int, default=2) -
Lisää tiedekunnalle vielä NumberRange-validaattori, joka sallii arvot ykkösestä kuutoseen. Vaihtoehto nolla ei siis kelpaa.
-
Lisätään lomakkeelle dynaamisesti haluttu määrä arvosanakenttiä. Kentät ovat ei-pakollisia: jos arvoa ei ole annettu, muita validaattoreita ei suoriteta. Jos arvo on annettu, sen on oltava välillä 0–5.
IntegerFieldmuuntaa syötteen kokonaisluvuksi itse, jotencoerce-parametria ei tarvita. Koodi suoritetaan luokan määrittelyn jälkeen:LKM = 10 for i in range(1, LKM + 1): # setattr luo luokkaan uuden attribuutin; nimen on oltava merkkijono setattr(Arvosanalaskuri, f"t{i}", IntegerField(str(i), validators=[ validators.Optional(), validators.NumberRange(min=0, max=5, message="Virheellinen arvosana"), ]))Huomaa
f"t{i}": silmukkamuuttuja on kokonaisluku, joten"t" + ikaatuisiTypeError-virheeseen. Samasta syystä kentän otsikko annetaan muodossastr(i). -
Vie lukumäärä parametrina templatelle ja tulosta arvosanakentät. Jinjassa merkkijonon ja luvun voi yhdistää
~-operaattorilla, joka muuntaa arvon merkkijonoksi automaattisesti:<table> <tr> <th scope="row">Tehtävänro</th> {% for i in range(1, lkm + 1) %} <th scope="col">{{ form["t" ~ i].label }}</th> {% endfor %} </tr> <tr> <th scope="row">Pisteet</th> {% for i in range(1, lkm + 1) %} <td>{{ tekstikentta(form["t" ~ i], koko=2) }}</td> {% endfor %} </tr> </table> - Kokeile, toimiiko lomake. Miten saisit virheilmoitukset näkyville? Yritä
värjätä arvosanakentän reuna punaisella, jos kentässä on virheellinen syöte —
lisää kentälle luokka silloin kun
kentta.errorsei ole tyhjä. - Miten tiedekunnan valinta toimisi radiopainikkeilla? Muuta kentän tyypiksi
RadioFieldja kokeile. Radiopainikkeilla on ehdottomasti oltava oletusvalinta. - Mitä jos opiskelija voisi kuulua useampaan tiedekuntaan? Mahdollista se
SelectMultipleField-tyyppisellä kentällä. -
Miten validointi toimii, kun kentän tyyppi vaihtui?
NumberRangeei enää kelpaa, koska kentän arvo on lista. Korvataan se omalla validaattorilla, joka kirjoitetaan luokan sisään:class Arvosanalaskuri(FlaskForm): tiedekunta = SelectMultipleField("Tiedekunta", choices=TIEDEKUNNAT, coerce=int) # nimen on oltava täsmälleen validate_<kentän nimi>, muuten funktiota ei kutsuta def validate_tiedekunta(self, field): kelvolliset = {avain for avain, nimi in TIEDEKUNNAT if avain != 0} if not field.data: raise ValidationError("Valitse vähintään yksi tiedekunta.") for arvo in field.data: if arvo not in kelvolliset: raise ValidationError("Valitse tiedekunta luettelosta.")Kenttäkohtaisen validaattorin nimen on oltava täsmälleen
validate_ja kentän nimi. Jos nimi on jotain muuta, funktiota ei kutsuta koskaan eikä virheestä huomaa mitään. Validointifunktio ottaa aina kaksi parametria: lomakkeen ja kentän. Yleiskäyttöisempiä validaattoreita varten kts. Custom validators. -
Oletuksena WTForms lukee tiedot vain POST-pyynnön rungosta. Jos tiedot tulevat GET-metodilla eli osoitteeseen koodattuina, ne on annettava erikseen
formdata-parametrina:@app.route("/wtlomake", methods=["GET", "POST"]) def wtlomake(): if request.method == "GET" and request.args: form = Arvosanalaskuri(formdata=request.args, meta={"csrf": False}) form.validate() else: form = Arvosanalaskuri(meta={"csrf": False}) if form.is_submitted(): form.validate() return render_template("wtlomake.html", form=form, lkm=LKM)Kokeile, saatko arvosanalaskurisi toimimaan kummallakin metodilla.
Vertaa malliratkaisuun (lähdekoodi, template). Lisätietoa: Solving Specific Problems.
Flask-WTF ja CSRF-suojaus
CSRF (Cross-site request forgery) tarkoittaa hyökkäystä, jossa toinen sivusto saa selaimen lähettämään pyynnön sinun sovellukseesi käyttäjän tietämättä. Suojaus perustuu siihen, että jokaiseen lomakkeeseen liitetään tunniste, jota hyökkääjä ei voi tietää.
Jos lomakkeen virheissä (form.errors) näkyy ilmoitus
{'csrf_token': ['The CSRF token is missing.']}
lomakkeelta puuttuu tunniste. Lisää templateen:
{{ form.csrf_token }}
Suojaus vaatii salaisen avaimen, jonka on pysyttävä samana suorituskertojen
välillä. Muuten saat virheilmoituksen The CSRF session token is missing.
Avainta ei kirjoiteta lähdekoodiin vaan luetaan ympäristöstä:
import os
from flask_wtf.csrf import CSRFProtect
app.secret_key = os.environ["SECRET_KEY"]
csrf = CSRFProtect(app)
Avaimen voi arpoa komennolla python -c "import secrets;
print(secrets.token_hex(32))" ja tallentaa se
cgi-bin-hakemiston ulkopuolelle tai ympäristömuuttujaan.
Tätä ohjaustehtävää on helpompi testata, jos suojauksen ottaa väliaikaisesti pois päältä:
form = Arvosanalaskuri(meta={"csrf": False})
# tai koko sovelluksen osalta
app.config["WTF_CSRF_ENABLED"] = False
Suoraan osoitteeseen kirjoitettujen parametrien mukana ei tule tunnistetta, joten GET-metodilla toimivat näkymät on tarvittaessa rajattava suojauksen ulkopuolelle. Huomaa pilkku yhden alkion tuplessa — ilman sitä kyseessä olisi merkkijono, jota Flask lukisi kirjain kerrallaan:
@app.route("/foo", methods=("GET",))
@csrf.exempt
def foo():
return "..."
Lisätietoa: CSRF Protection.
Sama validointi Pydanticilla
WTForms hoitaa kolme asiaa: validoinnin, lomakkeen renderöinnin ja CSRF-suojauksen. Jos renderöinnin tekee mieluummin itse Jinja-makroilla — kuten edellä — validointiin voi käyttää Pydanticia, joka on nykyisin Pythonin yleisin datan validointikirjasto. Se perustuu tyyppiannotaatioihin, joten sama malli kelpaa myöhemmin myös JSON-rajapinnan ja tietokannan kanssa.
Toteuta sama arvosanalaskuri uudelleen Pydanticilla ja vertaa lopputulosta WTForms-versioon.
pip install pydantic
-
Määrittele malli. Kenttien tyypit ja rajat annetaan annotaatioina, joten erillisiä validaattoriolioita ei tarvita:
from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator TIEDEKUNNAT = { 1: "Humanistis-yhteiskuntatieteellinen tiedekunta", 2: "Informaatioteknologian tiedekunta", 3: "Kasvatustieteiden ja psykologian tiedekunta", 4: "Liikuntatieteellinen tiedekunta", 5: "Matemaattis-luonnontieteellinen tiedekunta", 6: "Kauppakorkeakoulu", } LKM = 10 # tyyppialias, jota voi käyttää uudelleen Arvosana = Annotated[int, Field(ge=0, le=5)] class Arvosanalomake(BaseModel): # siivotaan ympäröivät välilyönnit kaikista merkkijonoista model_config = ConfigDict(str_strip_whitespace=True) etunimi: str = Field(min_length=2, max_length=50) sukunimi: str = Field(min_length=2, max_length=50) tiedekunta: int arvosanat: dict[str, Arvosana] = {} @field_validator("tiedekunta") @classmethod def tarkista_tiedekunta(cls, arvo): if arvo not in TIEDEKUNNAT: raise ValueError("Valitse tiedekunta luettelosta.") return arvoHuomaa
dict[str, Arvosana]: Pydantic validoi myös sanakirjan arvot, joten jokainen arvosana tarkistetaan automaattisesti välille 0–5. Kymmentä erillistä kenttää ei tarvitse luodasetattr-silmukalla niin kuin WTFormsissa. -
Pydantic ei tunne HTTP-lomakkeita, joten pyynnön data on muunnettava ensin. Muunnoksessa on kolme asiaa, jotka pitää hoitaa itse:
request.formonMultiDict, jossa samalla avaimella voi olla useita arvoja; kaikki arvot ovat merkkijonoja; ja tyhjä kenttä tulee tyhjänä merkkijonona eikä puutu kokonaan.def lomakedata(kentat): """MultiDict tavalliseksi sanakirjaksi, tyhjät kentät pois.""" data = {} for avain in kentat: arvot = [arvo.strip() for arvo in kentat.getlist(avain) if arvo.strip()] if not arvot: continue data[avain] = arvot if len(arvot) > 1 else arvot[0] return dataTyhjien kenttien poistaminen on olennaista: silloin puuttuva pakollinen kenttä tuottaa selkeän "kenttä puuttuu" -virheen sen sijaan, että tyhjä merkkijono kaatuisi tyyppimuunnokseen.
-
Pydanticin virheilmoitukset ovat englanniksi ja rakenteisessa muodossa. Muunnetaan ne kenttäkohtaiseksi sanakirjaksi, jota template osaa käyttää:
SUOMENNOKSET = { "missing": "Kenttä on pakollinen.", "string_too_short": "Arvo on liian lyhyt.", "string_too_long": "Arvo on liian pitkä.", "int_parsing": "Arvon on oltava kokonaisluku.", "greater_than_equal": "Arvo on liian pieni.", "less_than_equal": "Arvo on liian suuri.", } def virheet_kentittain(poikkeus): """ValidationError kenttänimi-viesti-sanakirjaksi.""" virheet = {} for virhe in poikkeus.errors(): # loc on polku: ("etunimi",) tai ("arvosanat", "t3") avain = ".".join(str(osa) for osa in virhe["loc"]) virheet[avain] = SUOMENNOKSET.get(virhe["type"], virhe["msg"]) return virheetKokeile tulostaa
poikkeus.errors()sellaisenaan lokiin, niin näet millaisiatype-arvoja Pydantic tuottaa. Omien validaattoreiden nostamatValueError-poikkeukset saavat tyypinvalue_error, ja niiden oma viesti tuleemsg-kentässä. -
Näkymäfunktio kokoaa nämä yhteen. Onnistunut käsittely päättyy uudelleenohjaukseen, epäonnistunut palauttaa lomakkeen virheineen ja käyttäjän aiemmilla arvoilla:
@app.route("/pydantic", methods=["GET", "POST"]) def pydanticlomake(): arvot = {} virheet = {} if request.method == "POST": arvot = lomakedata(request.form) # kentät t1...t10 omaksi sanakirjakseen arvot["arvosanat"] = { avain: arvo for avain, arvo in arvot.items() if avain.startswith("t") and avain[1:].isdigit() } try: lomake = Arvosanalomake(**arvot) app.logger.info("Lomake kelpasi: %s %s", lomake.etunimi, lomake.sukunimi) return redirect(url_for("pydanticlomake"), code=303) except ValidationError as poikkeus: virheet = virheet_kentittain(poikkeus) return render_template("pydantic.html", arvot=arvot, virheet=virheet, tiedekunnat=TIEDEKUNNAT, lkm=LKM) -
Template renderöi kentät omilla makroilla, jolloin merkkaus on täysin omissa käsissä ja pysyy XHTML-yhteensopivana:
{% macro tekstikentta(nimi, otsikko, koko=20) %} <p> <label for="{{ nimi }}">{{ otsikko }}</label> <input type="text" id="{{ nimi }}" name="{{ nimi }}" size="{{ koko }}" value="{{ arvot.get(nimi, '') }}" class="{{ 'virheellinen' if nimi in virheet else 'kentta' }}" /> {% if nimi in virheet %} <span class="virhe">{{ virheet[nimi] }}</span> {% endif %} </p> {% endmacro %} <form action="{{ url_for('pydanticlomake') }}" method="post" accept-charset="UTF-8"> {{ tekstikentta("etunimi", "Etunimi") }} {{ tekstikentta("sukunimi", "Sukunimi") }} <p> <label for="tiedekunta">Tiedekunta</label> <select id="tiedekunta" name="tiedekunta"> <option value="">Valitse tiedekunta</option> {% for numero, nimi in tiedekunnat.items() %} <option value="{{ numero }}"{% if arvot.get('tiedekunta') == numero|string %} selected="selected"{% endif %}> {{ nimi }} </option> {% endfor %} </select> {% if "tiedekunta" in virheet %} <span class="virhe">{{ virheet["tiedekunta"] }}</span> {% endif %} </p> <table> <tr> <th scope="row">Tehtävänro</th> {% for i in range(1, lkm + 1) %}<th scope="col">{{ i }}</th>{% endfor %} </tr> <tr> <th scope="row">Pisteet</th> {% for i in range(1, lkm + 1) %} <td>{{ tekstikentta("t" ~ i, "Tehtävä " ~ i, koko=2) }}</td> {% endfor %} </tr> </table> <p><input type="submit" name="laheta" value="Lähetä" /></p> </form>Arvosanakenttien virheet löytyvät avaimilla
arvosanat.t3, koska Pydanticinlockertoo koko polun tietorakenteessa. Muuta makroa niin, että se etsii virheen molemmilla nimillä, tai anna virheen avain parametrina. - Värjää virheellisten kenttien reuna punaisella tyylitiedostossa
.virheellinen-luokan avulla. Huomaa, että luokka tulee samasta lähteestä kuin virheilmoitus, joten niitä ei voi vahingossa unohtaa erikseen. - Kokeile lähettää lomake tyhjänä, yhden merkin nimillä, arvosanalla 9 ja
arvosanalla
abc. Vertaa virheilmoituksia WTForms-version ilmoituksiin. - Kirjoita osoiteriville käsin sellainen pyyntö, jossa on kenttä
tiedekunta=99. Meneekö se läpi? Entä jos lähetät kaksietunimi-kenttää samassa pyynnössä?
Pydantic validoi vain datan. Jos jätät WTFormsin pois, CSRF-suojaus on
hoidettava erikseen — esimerkiksi ottamalla käyttöön pelkkä
CSRFProtect-laajennus ilman lomakeluokkia ja lisäämällä tunniste
templateen. Sama koskee muutakin, mitä lomakekirjasto teki puolestasi:
kenttien renderöinti, otsikot ja aiempien arvojen palauttaminen jäävät nyt
sinulle.
Mieti lopuksi, kumpi tapa oli sinusta selkeämpi ja miksi. Kummallakin on paikkansa: WTForms säästää työtä palvelimella renderöidyissä lomakkeissa, Pydantic taas silloin kun sama malli validoi lomakkeen lisäksi JSON-rungon, asetustiedoston tai tietokannasta luetun rivin.
Lisätehtävä: tietojen validointi ilman kirjastoja

Luo kuvaa vastaava Flask-sivu ilman WTFormsia ja ilman Pydanticia — kaikki tarkistukset käsin. Tämä auttaa näkemään, mitä kirjastot oikeastaan tekevät puolestasi. Voit käyttää valmista pohjaa.
Lisää tarkistus, jossa varmistetaan, että etunimi ja sukunimi on täytetty ja tiedekunta valittu. Tee tarkistus vain jos lomake on lähetetty; sivulle ensimmäistä kertaa tultaessa ei herjata mistään.
-
Kerää virheilmoitukset yhteen sanakirjaan, jossa avaimena on kentän nimi ja arvona virheilmoitus:
arvot = {"etunimi": "", "sukunimi": "", "tiedekunta": ""} virheet = {} if request.method == "POST": for kentta in arvot: arvot[kentta] = request.form.get(kentta, "").strip() if not arvot[kentta]: virheet[kentta] = "Kenttä on pakollinen."Käytä
get-metodia oletusarvon kanssa: puuttuva kenttä on tavallinen tilanne eikä poikkeus. -
Muodosta sanakirja tiedekunnista, vie se templatelle ja luo sen perusteella alasvetovalikon sisältö:
TIEDEKUNNAT = { 0: "Valitse tiedekunta", 1: "Humanistis-yhteiskuntatieteellinen tiedekunta", 2: "Informaatioteknologian tiedekunta", 3: "Kasvatustieteiden ja psykologian tiedekunta", 4: "Liikuntatieteellinen tiedekunta", 5: "Matemaattis-luonnontieteellinen tiedekunta", 6: "Kauppakorkeakoulu", }Huomaa, että sanakirjan avaimet ovat kokonaislukuja ja lomakkeelta saadut tiedot aina merkkijonoja. Muunna arvo ennen vertailua.
- Tulosta virheilmoitukset kolmella tavalla: ensin yksi yleinen ilmoitus, sitten kaikki virheet lueteltuna lomakkeen alussa, ja lopuksi kunkin kentän viereen sitä koskeva ilmoitus.
- Aseta kunkin kentän oletusarvoksi edellisellä kerralla syötetty teksti tai valinta.
- Viikkotehtävien pistekentillä ei ole vielä
name-attribuuttia. Mitä niihin pitäisi laittaa, jotta saat kaikki pisteet varmasti oikein ja kätevästi käsiteltäväksi? Huomaa, että selain voi lähettää kentät missä järjestyksessä tahansa. - Tarkista, että pistekenttiin on syötetty luku väliltä 0–5, mutta älä herjaa tyhjästä kentästä. Tee tarkistus yhdessä silmukassa ja aseta virheellisten kenttien taustaväriksi punainen.
Kun sovellus toimii omalla koneella mutta ei
users.jyu.fi-palvelimella, älä tulosta virheitä
print-funktiolla: CGI-ympäristössä tuloste menee suoraan selaimelle ja
rikkoo vastauksen. Käytä
app.logger-lokitusta
ja kirjoita loki cgi-bin-hakemiston ulkopuolelle.
Käyttäjien kommentit