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.

Älä koskaan luota lomakkeelta tuleviin tietoihin

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.

Älä käytä globaaleja muuttujia Flask-sovelluksissa

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.

Malliratkaisu:

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 ei ole Python

Jinja-templateen voi kirjoittaa ohjausrakenteita, ja syntaksi muistuttaa Pythonia mutta ei ole sitä. Varmista syntaksi aina Jinjan dokumentaatiosta.

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")
Koko sovellus kerralla

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.

Tarkista työkalun ikä

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?

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.

Arvosanalaskuri-lomake, jossa on etunimi- ja sukunimikentät, tiedekunnan     valinta sekä taulukko viikkotehtävien pistekentistä.

Toteuta kuvaa vastaava arvosanalaskuri Flask-WTF-kirjastolla.

WTForms ja XHTML

WTForms renderöi kentät HTML5-tyyliin ilman sulkevaa kauttaviivaa (<input type="text">), mikä ei kelpaa XML-jäsentimelle. Ratkaisuja on kaksi:

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 %}

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
Pydantic ei suojaa CSRF-hyökkäykseltä

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

Arvosanalaskuri-lomake, jossa on etunimi- ja sukunimikentät, tiedekunnan     valinta sekä taulukko viikkotehtävien pistekentistä.

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.

  1. 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.

  2. 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.

  3. Tulosta virheilmoitukset kolmella tavalla: ensin yksi yleinen ilmoitus, sitten kaikki virheet lueteltuna lomakkeen alussa, ja lopuksi kunkin kentän viereen sitä koskeva ilmoitus.
  4. Aseta kunkin kentän oletusarvoksi edellisellä kerralla syötetty teksti tai valinta.
  5. 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.
  6. 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.
Virheiden etsiminen palvelimella

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

Kommentoi Lisää kommentti