Sessiot, autentikointi ja relaatiotietokannat

Harjoitellaan sessioiden käyttöä www-sovelluksessa, lisätään sovellukseen kirjautuminen ja tutustutaan relaatiotietokantoihin.

Tehtävän apuna kannattaa käyttää seuraavia dokumentteja:

Laskuri sessioilla

Etsi edellisessä pääteohjauksessa tekemäsi laskurisovellus. Korjataan laskuri toimimaan kunnolla sessioiden avulla: ohjelman pitää muistaa aiemmin saatu summa.

Sessio on allekirjoitettu, ei salattu

Flaskin oletussessio kulkee kokonaisuudessaan selaimen evästeessä. Sisältö on base64-koodattua JSONia, jonka kuka tahansa evästeen näkevä voi lukea ilman salaista avainta. Allekirjoitus estää sisällön muuttamisen, ei sen lukemista. Sessioon kuuluu siis vain käyttäjän tunniste — ei salasanoja eikä henkilötietoja.

Mallivastaus: ajossa oleva sovellus, oma.py, laskuri.html ja kirjaudu.html.

Sessio ei päivity?

Flask ei huomaa muutosta, jos muokkaat sessiossa olevaa listaa tai sanakirjaa paikallaan. Silloin sessio on merkittävä itse muuttuneeksi:

session["lista"].append("foo")
session.modified = True

Autentikointi

Kolme asiaa, jotka erottavat harjoituksen oikeasta kirjautumisesta

Mallivastaus: ajossa oleva sovellus, kirjaudu.py, kirjaudu2.html ja kirjautunut.html.

Tietokannat

Harjoitellaan tietokantojen käyttämistä. Tehtävissä käytetään SQLite-tietokantaa, jota hallitaan komentoriviltä sqlite3-työkalulla. Tietokantatehtäviä voi kokeilla myös ajamalla Python-ohjelmaa suoraan komentoriviltä.

Tämän osuuden ajossa olevat mallisovellukset: reseptit ja kirjautuminen tietokantaa vasten.

Tämän osuuden tiedostot:

Kannattaa tehdä tehtävät ensin itse ja vilkaista mallivastausta vasta sitten. Yksittäisiin kohtiin liittyvät mallivastaukset on linkitetty myös kunkin luvun loppuun.

Tietokannan luominen

Tiedosto-oikeudet

Vanhemmissa ohjeissa neuvottiin antamaan tietokantaan ja sen hakemistoon kirjoitusoikeus kaikille (chmod a+rw). Älä tee niin. CGI-ohjelma suoritetaan omalla tunnuksellasi, joten sovellus kirjoittaa kantaan sinuna. Riittävät oikeudet ovat siis:

chmod 700 ~/public_html/hidden
chmod 600 ~/public_html/hidden/resepti.db
chmod 600 ~/public_html/hidden/avain.txt

Muista myös, että kirjoitusoikeus tarvitaan sekä tiedostoon että sen hakemistoon: SQLite luo viereen -wal- ja -shm-tiedostot. Siksi kanta ei voi olla cgi-bin-hakemistossa, johon ei saa olla kirjoitusoikeutta — eikä se saa olla www-hakemistossa, koska silloin kuka tahansa voi ladata koko kannan selaimellaan.

Tietokantayhteys

Web-sovelluksessa yhteys avataan kerran pyyntöä kohti ja suljetaan pyynnön päätteeksi. Flaskissa se hoituu g-oliolla ja teardown_appcontext-koukulla:

import os
import sqlite3
from flask import g

KANTA = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                     "..", "..", "..", "hidden", "resepti.db")

def kanta():
    if "kanta" not in g:
        g.kanta = sqlite3.connect(KANTA)
        # sarakkeisiin voi viitata nimellä: rivi["Nimi"]
        g.kanta.row_factory = sqlite3.Row
        # viite-eheystarkistukset, yhteyskohtainen asetus
        g.kanta.execute("PRAGMA foreign_keys = ON")
    return g.kanta

@app.teardown_appcontext
def sulje_kanta(poikkeus):
    yhteys = g.pop("kanta", None)
    if yhteys is not None:
        yhteys.close()

Polku johdetaan tiedoston sijainnista eikä työhakemistosta: CGI-prosessin työhakemisto ei välttämättä ole se, mitä oletat, ja väärällä polulla SQLite luo hiljaisesti uuden tyhjän kannan.

Yleinen virhe: finally ja määrittelemätön muuttuja
try:
    yhteys = sqlite3.connect(KANTA)
except sqlite3.Error:
    return "tietokanta ei aukea"
finally:
    yhteys.close()          # NameError, jos avaaminen epäonnistui

Jos yhteyden avaaminen epäonnistuu, muuttujaa ei ole olemassa ja finally-lohko kaatuu peittäen alkuperäisen virheen. Komentoriviltä ajettavassa skriptissä käytä contextlib.closing-apuria, ja Flask-sovelluksessa yllä olevaa koukkua.

Kyselyt

SQL-injektio

Muuttujaa ei koskaan sijoiteta SQL-lauseeseen merkkijono-operaatioilla eikä f-stringillä. Käyttäjän syöte voi silloin sisältää SQL-koodia, joka suoritetaan osana kyselyä, ja koko kannan saa luettua tai tuhottua. Kyselyparametreja käytettäessä tietokanta huolehtii lainausmerkeistä ja tyypeistä, eikä syöte voi muuttaa kyselyn rakennetta. Kts. SQL Injection.

Kyselyparametrilla voi korvata vain arvon. Taulun tai sarakkeen nimeä ei voi antaa parametrina; jos se on valittava ohjelmallisesti, valinta tehdään omasta sallittujen nimien luettelosta.

Sarakkeiden nimet ja kirjainkoko

sqlite3.Row ei välitä kirjainkoosta: rivi["nimi"] ja rivi["Nimi"] tuottavat saman tuloksen. Jos rakennat oman row_factory-funktion, joka palauttaa tavallisia sanakirjoja, kirjainkoolla on merkitystä — ja avaimen nimi tulee silloin taulun määrittelystä, ei kyselystä. Anna tällöin sarakkeelle AS-alias, jolloin nimi on varmasti se, mitä odotat.

Lisääminen

Poistaminen ja päivittäminen

DELETE FROM Resepti WHERE ReseptiID = :id
UPDATE Resepti SET Henkilomaara = :maara WHERE ReseptiID = :id
Viittaa aina perusavaimeen

Kun poistat tai päivität tietyn rivin, viittaa siihen perusavaimella, joka yksilöi rivin. Muilla ehdoilla osuu helposti useampaan riviin kuin oli tarkoitus — eikä virhettä huomaa mistään, koska SQL tekee juuri sen, mitä pyydettiin.

Transaktiot

Transaktiolla hallitaan operaatiokokonaisuuksia: jos yksikin lisäys epäonnistuu, kaikki perutaan. Siisteintä on antaa with-lohkon hoitaa asia. Lohkon lopussa tehdään commit, ja jos lohkosta poistutaan poikkeuksen takia, tehdään rollback:

try:
    yhteys = kanta()
    with yhteys:
        kursori = yhteys.execute(
            "INSERT INTO Ruokalaji (Nimi) VALUES (?)", (ruokalaji,))

        # lastrowid kertoo juuri lisätyn rivin avaimen
        uusi_laji = kursori.lastrowid

        yhteys.execute("""
            INSERT INTO Resepti (Nimi, Henkilomaara, RuokalajiID)
            VALUES (:nimi, 2, :laji)
        """, {"nimi": resepti, "laji": uusi_laji})

except sqlite3.IntegrityError:
    # molemmat lisäykset peruuntuivat: kantaan ei jäänyt kumpaakaan
    flash("Lisäys ei onnistunut, muutokset peruttiin.")

Kokeile antaa ruokalajille nimi, joka on jo olemassa, ja tarkista komentoriviltä, ettei kumpikaan rivi jäänyt kantaan. Viimeksi lisätyn rivin avaimen saa muuten myös kyselyllä SELECT last_insert_rowid().

Mitä with-lohko tekee

Tietokantayhteyden käyttäminen with-lohkossa on dokumentoitu sqlite3-kirjaston ohjeessa. Lohko hoitaa transaktion — ei yhteyttä:

with yhteys -lohkon toiminta
TilanneMitä tapahtuu
Lohko päättyy normaalisti Kutsutaan commit. Muutokset jäävät voimaan.
Lohkossa nousee poikkeus Kutsutaan rollback. Poikkeus jatkaa matkaansa, joten se on otettava kiinni except-lohkolla.
Yhteyden sulkeminen Ei tapahdu. Yhteys suljetaan erikseen teardown_appcontext-koukussa tai contextlib.closing-apurilla.
Sisäkkäiset with yhteys -lohkot Eivät toimi odotetusti: SQLitessä ei ole sisäkkäisiä transaktioita ilman tallennuspisteitä.
Yhteys avattu autocommit=True-tilassa Lohko ei tee mitään, koska transaktiota ei ole auki.

Verkossa näkee usein väitteen, että with-lohko sulkisi myös yhteyden. Se ei pidä paikkaansa — kokeile itse: yhteydellä voi tehdä kyselyitä vielä lohkon jälkeen.

Sama ilman with-lohkoa

Näin sama transaktio kirjoitetaan käsin. Vertaa tätä edelliseen esimerkkiin: with-lohko tekee saman ilman else-haaraa ja ilman erillisiä commit- ja rollback-kutsuja.

yhteys = kanta()

try:
    kursori = yhteys.execute(
        "INSERT INTO Ruokalaji (Nimi) VALUES (?)", (ruokalaji,))

    uusi_laji = kursori.lastrowid

    yhteys.execute("""
        INSERT INTO Resepti (Nimi, Henkilomaara, RuokalajiID)
        VALUES (:nimi, 2, :laji)
    """, {"nimi": resepti, "laji": uusi_laji})

except sqlite3.Error:
    # Peruminen on tehtävä itse. Huomaa myös, ettei suoritus saa jatkua
    # normaalisti tämän jälkeen: ilman return-lausetta tai else-haaraa koodi
    # etenisi riville, jolla tehdään commit.
    yhteys.rollback()
    flash("Lisäys ei onnistunut, muutokset peruttiin.")

else:
    # commit kuuluu else-haaraan eikä try-lohkon loppuun. Muuten try nappaisi
    # myös commitin omat virheet ja perisi ne väärässä kohdassa.
    yhteys.commit()
    flash("Ruokalaji ja resepti lisättiin.")

Käsin kirjoitettuna virhemahdollisuuksia on kolme: rollback unohtuu, commit unohtuu, tai suoritus jatkuu virheen jälkeen kuin mitään ei olisi tapahtunut. with-lohko poistaa kaikki kolme, joten käytä sitä aina kun voit.

Monimutkaisemmat tulostukset

Listataan kaikki ruokalajit otsikoiksi ja kunkin alle siihen liittyvät reseptit. Tee yksi kysely, joka hakee kaiken kerralla ja järjestää tuloksen valmiiksi — älä tee silmukassa uutta kyselyä jokaiselle ruokalajille.

SELECT Ruokalaji.Nimi AS Ruokalaji, Resepti.Nimi AS Resepti
FROM Ruokalaji LEFT OUTER JOIN Resepti
     ON Ruokalaji.RuokalajiID = Resepti.RuokalajiID
ORDER BY Ruokalaji.Nimi, Resepti.Nimi

LEFT OUTER JOIN ottaa mukaan myös ne ruokalajit, joihin ei liity yhtään reseptiä. Niiden riveillä Resepti on tyhjä. Ryhmittely tehdään templatessa Jinjan groupby-filtterillä. Filtteri lajittelee rivit itse ryhmittelyavaimen mukaan, joten ryhmät tulevat aakkosjärjestyksessä; ORDER BY ratkaisee sen, missä järjestyksessä rivit ovat kunkin ryhmän sisällä:

{% for ruokalaji, rivit in lajeittain|groupby("Ruokalaji") %}
  <h3>{{ ruokalaji }}</h3>

  {% set omat = rivit|selectattr("Resepti")|list %}

  {% if omat %}
    <ul>
      {% for r in omat %}
        <li>{{ r["Resepti"] }}</li>
      {% endfor %}
    </ul>
  {% else %}
    <p>Ei reseptejä.</p>
  {% endif %}
{% endfor %}

Filtteri palauttaa listan pareja, jotka puretaan silmukassa kahteen muuttujaan: ensimmäinen on ryhmittelyavain ja toinen tavallinen lista kyseisen ryhmän riveistä. Listan voi suodattaa, laskea tai käydä läpi useampaan kertaan. selectattr("Resepti") poimii ne rivit, joilla sarakkeessa on arvo, eli pudottaa pois LEFT OUTER JOIN -kyselyn tuottamat tyhjät rivit.

Vanhemmissa esimerkeissä sama tehtiin vertaamalla riviä edelliseen loop.previtem-muuttujan avulla. Se toimii, mutta tuottaa helposti epävalidia merkkausta — listan aloitus ja lopetus jäävät eri haaroihin — ja kaatuu UndefinedError-virheeseen, jos tuloksessa on vain yksi rivi. groupby on sekä lyhyempi että turvallisempi.

Jinjan groupby ei ole SQL:n GROUP BY

Samanniminen komento löytyy myös SQL:stä, mutta ne tekevät eri asian. SQL:n GROUP BY tiivistää rivit: ryhmää kohti palautuu yksi rivi, ja yksittäiset arvot ovat saatavilla vain koostefunktioiden kautta (COUNT, SUM, MAX). Jinjan groupby ei hävitä mitään: se lajittelee rivit ja niputtaa ne listoiksi, joissa kaikki alkuperäiset rivit ovat tallella.

Jos haluat tietää, montako reseptiä kussakin ruokalajissa on, se on SQL:n työtä:

SELECT Ruokalaji.Nimi, COUNT(Resepti.ReseptiID) AS Lukumaara
FROM Ruokalaji LEFT OUTER JOIN Resepti
     ON Ruokalaji.RuokalajiID = Resepti.RuokalajiID
GROUP BY Ruokalaji.RuokalajiID, Ruokalaji.Nimi

Jos taas haluat näyttää kaikki reseptit ruokalajeittain, ryhmittely tehdään vasta esitysvaiheessa.

Ryhmittely Python-koodissa

Ryhmittelyn voi tehdä myös Python-koodissa ja viedä templatelle valmiiksi ryhmitelty rakenne. MVC-mielessä se on jopa puhtaampaa, koska templateen jää vain kaksi sisäkkäistä silmukkaa.

Yksinkertaisin tapa on koota sanakirja, jonka avaimena on ruokalaji ja arvona lista sen resepteistä:

lajeittain = {}

for rivi in rivit_kannasta:
    # setdefault luo tyhjän listan, jos avainta ei vielä ole, ja palauttaa sen
    reseptit = lajeittain.setdefault(rivi["Ruokalaji"], [])

    # LEFT OUTER JOIN tuottaa reseptittömälle ruokalajille rivin, jossa
    # Resepti on None. Ruokalaji tulee mukaan, mutta listaan ei lisätä mitään.
    if rivi["Resepti"]:
        reseptit.append(rivi["Resepti"])
{% for ruokalaji, reseptit in lajeittain.items() %}
  <h3>{{ ruokalaji }}</h3>

  {% if reseptit %}
    <ul>
      {% for nimi in reseptit %}
        <li>{{ nimi }}</li>
      {% endfor %}
    </ul>
  {% else %}
    <p>Ei reseptejä.</p>
  {% endif %}
{% endfor %}

Sanakirja säilyttää lisäysjärjestyksen, joten ryhmät tulevat siinä järjestyksessä, jossa ne kyselyn tuloksessa ensimmäisen kerran esiintyivät.

Toinen vaihtoehto on itertools.groupby, joka ei rakenna uutta tietorakennetta, vaan niputtaa peräkkäiset rivit:

from itertools import groupby
from operator import itemgetter

lajeittain = [
    (laji, [rivi for rivi in rivit if rivi["Resepti"]])
    for laji, rivit in groupby(rivit_kannasta, key=itemgetter("Ruokalaji"))
]
itertools.groupby vaatii järjestetyn syötteen

Toisin kuin Jinjan samanniminen filtteri, itertools.groupby ei lajittele syötettä. Se katkaisee ryhmän aina kun avain vaihtuu, joten järjestämättömällä syötteellä sama avain tuottaa useita ryhmiä:

itertools.groupby: [('Jälkiruoka', ['Aprikoosikiisseli']),
                    ('Pääruoka',   ['Banaanicurry']),
                    ('Jälkiruoka', ['Rommikakku'])]

sanakirja:         {'Jälkiruoka': ['Aprikoosikiisseli', 'Rommikakku'],
                    'Pääruoka':   ['Banaanicurry']}

Sanakirja kerää rivit oikein riippumatta järjestyksestä. itertools.groupby vaatii, että kyselyssä on ORDER BY — ja jos joku myöhemmin muuttaa kyselyn järjestystä, tulostus hajoaa hiljaa.

Milloin kumpikin? Sanakirja on selkein silloin, kun tiedät jo valmiiksi, mitä kustakin rivistä tarvitaan, ja se kestää järjestyksen muutokset. itertools.groupby on kätevä, kun haluat säilyttää rivit kokonaisuudessaan etkä halua päättää etukäteen, mitä kenttiä niistä käytetään — ja kun rivejä on niin paljon, ettei niitä haluta kopioida uuteen rakenteeseen. Jinjan groupby taas säästää Python-puolen kokonaan, jos ryhmittely on ainoa asia, joka tulokselle tehdään.

Tarkista lopuksi validaattorilla, että otsikot ja listat tulostuvat oikein rakenteellisesti.

Mallivastaus: ajossa oleva sovellus, db.py ja reseptit.html.

Kirjautuminen ja tietokanta

Yleensä tunnus ja salasana tarkistetaan tietokannasta. Mallitietokannassa on Kayttaja-taulu, jossa on kolme tunnusta:

TunnusSalasana
foobar
barfoo
opiskelijaties4080

Kannassa ei tietenkään ole salasanoja, vaan niistä lasketut tarkisteet, jotka on tehty generate_password_hash-funktiolla. Muokkaa aiemmin tekemääsi kirjautumista siten, että tunnus haetaan kannasta:

rivi = kanta().execute("""
    SELECT Tarkiste AS tarkiste
    FROM Kayttaja
    WHERE Tunnus = :tunnus
""", {"tunnus": tunnus}).fetchone()

if rivi is None or not check_password_hash(rivi["tarkiste"], salasana):
    flash("Tunnus tai salasana on väärin.")
    return redirect(url_for("start"), code=303)

Edellytä kirjautumista reseptisivuille @auth-dekoraattorilla.

Mallivastaus: ajossa oleva sovellus, dbkirjaudu.py ja kirjaudu-db.html.

PostgreSQL

PostgreSQL-tietokantaa käsitellään samalla PEP 249 -rajapinnalla kuin SQLiteä; vain ajuri ja yhteyden avaaminen vaihtuvat. Nykyinen ajuri on psycopg 3:

pip install "psycopg[binary]"
import os
import psycopg

# Yhteystiedot luetaan ympäristöstä. Niitä ei kirjoiteta lähdekoodiin eikä
# versionhallintaan.
with psycopg.connect(os.environ["DATABASE_URL"]) as yhteys:
    with yhteys.cursor() as kursori:
        kursori.execute("SELECT Nimi FROM Resepti WHERE Henkilomaara = %s", (4,))
        print(kursori.fetchall())

Huomaa, että PostgreSQL käyttää eri parametrimerkintää kuin SQLite: %s tai %(nimi)s SQLiten ?- ja :nimi-merkintöjen sijaan.

SQLite ja PostgreSQL
SQLitePostgreSQL
Automaattinen avain INTEGER PRIMARY KEY SERIAL tai GENERATED BY DEFAULT AS IDENTITY
Viimeksi lisätty avain kursori.lastrowid INSERT ... RETURNING id
Kyselyparametri ? tai :nimi %s tai %(nimi)s
Aikaleima DATETIME (tekstiä) TIMESTAMP
Tietotyypit Löysät, ellei käytä STRICT-tauluja Tiukat

PostgreSQL-tietokantaa pääsee kokeilemaan kurssin viimeisessä viikkotehtävässä.

MySQL

MySQL ei ole enää käytettävissä

PythonAnywheren uusilla ilmaistileillä MySQL-tietokanta ei ole enää käytettävissä, joten sitä ei voi kurssin puitteissa kokeilla muuten kuin omalla koneella. Kurssin tehtävissä käytetään SQLiteä ja myöhemmin PostgreSQLiä.

Jos törmäät MySQL:ään muualla, muista ainakin nämä erot: kyselyparametrit ovat muotoa %(nimi)s, automaattinen avain on AUTO_INCREMENT, transaktio aloitetaan komennolla START TRANSACTION, ja merkkijonovertailu on oletuksena kirjainkokoriippumaton toisin kuin useimmissa muissa tietokannoissa.

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti