Osan 3 etapit
  • Sovelluksessa käytetyt lomakkeet validoivat syötetyn tiedon.
  • Sovelluksessa on mahdollisuus rekisteröitymiseen.
  • Sovelluksessa on mahdollisuus kirjautumiseen.
    • Kirjoita testitunnusten kirjautumiseen vaaditut tiedot README.md-tiedostoon.
  • Sovelluksessa on ainakin kaksi tietokantataulua.
  • Ainakin yhden tietokantataulun tietoihin liittyy täysi CRUD-toiminnallisuus sovelluksen kautta (yhden rivin luominen, yhden rivin tietojen näyttö, yhden rivin tietojen muokkaus, yhden rivin tietojen poisto, rivien listaus).
  • Githubissa on issuet päällä, jotta koodikatselmointi on mahdollista.
  • Commit-viestit ovat yhä järkeviä ja kuvaavat tehtyjä lisäyksiä ja/tai muutoksia.
  • Herokussa käytetään Herokun tarjoamaa PostgreSQL-tietokannanhallintajärjestelmää.

Tietokantasovellus-kurssin kolmannen osan materiaali sisältää esimerkin lomakkeiden luomiseen käytettävän wtforms-pakkauksen käytöstä sekä kirjautumisesta. Materiaalissa tarkastellaan myös erillisiä konfiguraatioita projektin tuotanto- ja kehitysympäristöjä varten sekä PostgreSQL-tietokannan käyttöä.

Huomaathan, että materiaali ei sisällä kaikkea harjoitustyöhön tarvittavaa. Oman harjoitustyön tulee luonnollisesti olla erillinen tästä materiaalista.

Kirjasto lomakkeiden luomiseen

WTForms on kirjasto lomakkeiden luomiseen sekä lomakkeissa käsiteltävän datan validointiin. Jatketaan edellisen osan esimerkkiä ja muunnetaan tehtävien syöttöön tarkoitettu lomake WTFormsia käyttäväksi. Flaskia varten WTFormsista löytyy integraatiopakkaus flask-wtf.

~/todosovellus$ source venv/bin/activate
(venv) ~/todosovellus$ pip install flask-wtf
Collecting flask-wtf
...
Successfully installed WTForms-2.1 flask-wtf-0.14.2
(venv) ~/todosovellus$ 

Lomaketta kuvaavan luokan luominen

Lomakkeita varten luodaan omat luokat. Luodaan kansioon tasks tiedosto forms.py, joka tulee sisältämään tehtävien käsittelyyn tarvittavat lomakkeet. Luodaan lomakkeesta ensimmäinen versio, joka sisältää tekstikentän tehtävän nimeä varten. Lomakkeelle määritellään ainakin toistaiseksi lisäksi metatietona muuttuja csrf = false, millä turvautuminen cross-site request forgery -hyökkäyksiä vastaan kytketään pois päältä.

from flask_wtf import FlaskForm
from wtforms import StringField

class TaskForm(FlaskForm):
    name = StringField("Task name")
 
    class Meta:
        csrf = False

Jokainen flask-wtf -integraatiota käyttävä lomakeluokka perii FlaskForm-luokan (merkitään class Luokka(perittava). Erilaisia kenttätyyppejä (esim. tekstikenttä, StringField) löytyy WTFormsin dokumentaatiosta sekä netin hakukoneista.

Lomakkeen lisääminen näkymään

Lomakkeiden lisääminen näkymään tapahtuu views.py-tiedostossa. Muokataan uuden lomakkeen näyttämiseen tarkoitettua funktiota tasks_form siten, että sen käyttämä render_template saa parametrinaan form-nimisen muuttujan, jonka arvona on uusi TaskForm-olio.

from flask import render_template, request, redirect, url_for

from application import app, db
from application.tasks.models import Task
from application.tasks.forms import TaskForm

@app.route("/tasks/new/")
def tasks_form():
    return render_template("tasks/new.html", form = TaskForm())

# ...

Nyt käyttäjän mennessä polkuun /tasks/new, luotavan HTML-sivun käytössä on lomake. Lisätään lomake vielä HTML-sivulle. WTFormsin avulla luotavilla lomakkeilla on lomakkeen kenttää kuvaava nimi label sekä itse lomakekenttä. Alla luotava lomake näyttää lomakkeen tekstikentälle nimen "Task name".

  {% extends "layout.html" %}

  {% block body %}
  <form method="POST" action="{{ url_for('tasks_create') }}">
    <table>
      <tr>
	<td>
	  {{ form.name.label }}
	</td>
	<td>
	  {{ form.name }}
	</td>
      </tr>
      <tr>
	<td colspan="2">
	  <input type="submit" value="Add a new task"/>
	</td>
      </tr>
  </form>
  {% endblock %}

Nyt lomake näyttää seuraavalta.

Lomake tekstikentällä.

Lomakekenttien lisääminen

Lomakekenttien lisääminen tapahtuu lisäämällä halutut kentät lomaketta kuvaavaan luokkaan sekä lisäämällä lomakkeen kentät lomakkeen näyttämiseen käytettävään HTML-dokumenttiin.

Muokataan lomaketta ja lisätään siihen kenttä, jonka avulla käyttäjä pystyy määrittelemään tehtävän tehdyksi tai tekemättömäksi. Tämä onnistuu BooleanField-tyyppisellä kentällä. Muokataan kansiossa tasks olevaa tiedostoa forms.py seuraavaan muotoon.

from flask_wtf import FlaskForm
from wtforms import BooleanField, StringField

class TaskForm(FlaskForm):
    name = StringField("Task name")
    done = BooleanField("Done")
  
    class Meta:
        csrf = False

Nyt näkymää varten luotava lomakeolio sisältää kentät nimeä ja tehdyksi merkkaamista varten. Muokataan vielä HTML-sivua siten, että uusi lomakkeen kenttä näkyy myös HTML-sivulla. Alla määritelty HTML-sivu näyttää kaksi lomakkeen kenttää name ja done sekä niihin liitetyt nimet "Task name" ja "Done".

  {% extends "layout.html" %}

  {% block body %}
  <form method="POST" action="{{ url_for('tasks_create') }}">
    <table>
      <tr>
	<td>
	  {{ form.name.label }}
	</td>
	<td>
	  {{ form.name }}
	</td>
      </tr>
      <tr>
	<td>
	  {{ form.done.label }}
	</td>
	<td>
	  {{ form.done }}
	</td>
      </tr>
      <tr>
	<td colspan="2">
	  <input type="submit" value="Add a new task"/>
	</td>
      </tr>
  </form>
  {% endblock %}

Lomake näyttää nyt seuraavalta.

Lomake kahdella kentällä.

Lomakkeen avulla lähetetyn tiedon tallentaminen

Kun tarkastelemme edellä luodun lomakkeen toimintaa, lomakkeen done kentän arvo ei tallennu tietokantaan. Mitä ihmettä?

Tarkastellaan lomakkeesta lähetetyn tiedon tallentamiseen tarkoitettua tasks_create-funktiota.

# ...
@app.route("/tasks/", methods=["POST"])
def tasks_create():
    t = Task(request.form.get("name"))
  
    db.session().add(t)
    db.session().commit()
  
    return redirect(url_for("tasks_index"))
# ...

WTFormsin avulla luodun lomakkeen kautta tapahtunut tehtävän lisääminen onnistui aiemmin osittain tuurilla. Kun loimme lomakkeen ensimmäisen version, lomakkeeseen tuli kenttä nimeltä name. Samaa kenttää käytettiin myös lomakkeelta saadun tiedon tallentamiseen tarkoitetussa funktiossa.

WTFormsia käytettäessä lomakkeen käsittely tapahtuu hieman eri tavalla. Pyynnössä tulevasta lomakedatasta request.form voidaan luoda lomaketta kuvaava olio, esim form = TaskForm(request.form). Nyt form-olion kautta pääsee lomakekenttiin sekä niiden sisältämään dataan.

Lomakekenttien data löytyy kenttää kuvaavan muuttujan alta data-nimisestä muuttujasta. Esimerkiksi lomakkeessa form olevan tekstikentän nimeltä name arvo löytyy muuttujasta data, joka kirjoitetaan kokonaisuudessaan muodossa form.name.data. Vastaavasti tehtävän tehdyksi merkkaavan muuttujan done arvo löytyy muuttujasta form.done.data.

Muokataan funktion tasks_create toiminnallisuutta siten, että funktiossa luodaan Task-olio lomaketta hyödyntäen. Tämä tapahtuu seuraavasti.

# ...
  
@app.route("/tasks/", methods=["POST"])
def tasks_create():
    form = TaskForm(request.form)

    t = Task(form.name.data)
    t.done = form.done.data
  
    db.session().add(t)
    db.session().commit()
  
    return redirect(url_for("tasks_index"))
# ...

Nyt lomakkeen avulla lähetetty data tallennetaan myös tietokantaan.

Lomake kahdella kentällä tallentaa tietoa tietokantaan.

Lomakkeen kautta lähetetyn tiedon validointi

Lomakkeiden avulla lähetettävä tieto halutaan usein validoida ennen sen tallentamista tietokantaan. Validointi sisältää muunmuassa toivotun muodon tarkastamisen, tyhjien syötteiden karsinnan ym ym. WTForms tarjoaa lomakkeiden validointiin valmista toiminnallisuutta.

Mikäli lomakkeen haluaa validoida, tulee lomaketta kuvaavaan luokkaan määritellä validointisäännöstö lomakkeiden kentille, lomaketta kuvaavalle HTML-sivulle lisätä toiminnallisuus mahdollisten virheviestien näyttämiseksi, sekä tiedon tallentamiseen keskittyvään funktioon tulee lisätä validointikutsu.

Lisätään tehtävien lisäämiseen käytettyyn lomakkeeseen esimerkinomaisesti tekstikenttään syötetyn merkkijonon pituuden validointi. Haluamme, että merkkijono on vähintään kaksi merkkiä pitkä.

Muokkaamme ensin lomaketta kuvaavaa luokkaa. Validoitavaan kenttään lisätään validointisäännöt -- alla tekstikentän pituuden tulee olla vähintään kaksi.

from flask_wtf import FlaskForm
from wtforms import BooleanField, StringField, validators

class TaskForm(FlaskForm):
    name = StringField("Task name", [validators.Length(min=2)])
    done = BooleanField("Done")
  
    class Meta:
        csrf = False

Muokataan seuraavaksi HTML-sivua siten, että se näyttää mahdolliset validointivirheet. Validointivirheet tallennetaan lomakkeeseen kenttäkohtaisesti kunkin kentän alla sijaitsevaan muuttujaan nimeltä errors. Virheitä voi olla useita, joten muuttuja errors on lista, joka tulee käydä läpi kun se tulostetaan.

  {% extends "layout.html" %}

  {% block body %}
  <form method="POST" action="{{ url_for('tasks_create') }}">
    <table>
      <tr>
	<td>
	  {{ form.name.label }}
	</td>
	<td>
	  {{ form.name }}
	</td>
	<td>
	  <ul>
	    {% for error in form.name.errors %}
	    <li>{{ error }}</li>
	    {% endfor %}
	  </ul>
	</td>
      </tr>
      <tr>
	<td>
	  {{ form.done.label }}
	</td>
	<td>
	  {{ form.done }}
	</td>
	<td>
	</td>
      </tr>
      <tr>
	<td colspan="3">
	  <input type="submit" value="Add a new task"/>
	</td>
      </tr>
  </form>
  {% endblock %}

Lomakkeen validointi tapahtuu osana funktiota, jonka vastuulla on lomakkeelta lähetetyn tiedon vastaanottaminen sekä käsittely. Konkreettinen validointi tapahtuu lomaketta kuvaavan olion validate-metodin avulla -- mikäli lomakkeesta saatu tieto ei ole validia, tulee käyttäjälle näyttää lomakesivu uudestaan. Lomakesivulle annetaan parametrina aiemmin saatu lomakeolio, jolloin sivulla näytetään aiemmin syötetyt arvot.

# ...
@app.route("/tasks/", methods=["POST"])
def tasks_create():
    form = TaskForm(request.form)

    if not form.validate():
        return render_template("tasks/new.html", form = form)

    t = Task(form.name.data)
    t.done = form.done.data

    db.session().add(t)
    db.session().commit()

    return redirect(url_for("tasks_index"))

Nyt lomakkeella on validointi. Lisää validointisäätöjä sekä lisää validointisäännöistä löytyy osoitteesta https://wtforms.readthedocs.io/en/stable/validators.html sekä hakukoneista.

Lomake kahdella kentällä.

 

Lomakkeet ja tietokannan kentät

WTFormsin ja SQLAlchemyn avulla käyttäjä voi luoda lomakkeita myös suoraan tietokantaa kuvaavista luokista, kts. esim. https://wtforms.readthedocs.io/en/stable/ext.html. Tässä materiaalissa tuota toiminnallisuutta ei kuitenkaan käsitellä.

Laajemmin ajatellen aina välillä on hyvä miettiä lomakkeita sen kautta mitä käyttäjälle halutaan kullakin sivulla näyttää, eikä sen kautta mitä tietokantaan halutaan tallentaa.

Käyttäjät ja kirjautuminen

Tarkastellaan seuraavaksi käyttäjien hallintaa web-sovelluksissa. Käyttäjien hallinta tapahtuu käytännössä palvelimen ja selaimen välisessä kommunikoinnissa välitettävien evästeiden avulla. Kun käyttäjä kirjautuu kirjautumislomakkeen (tai muun vastaavan) avulla, palvelin tunnistaa käyttäjän ja lähettää osana kirjautumispyynnön vastausta selaimelle evästeen. Jatkossa selain lähettää vastaanotetun evästeen palvelimelle jokaisen sivulle tehtävän pyynnön yhteydessä, jolloin palvelin osaa evästeen perusteella yhdistää pyynnön kirjautuneeseen käyttäjään. Kun käyttäjä kirjautuu ulos palvelusta, palvelimella oleva evästeeseen liitetty tieto.

Flask tarjoaa käyttäjien ja kirjautumisen hallintaan paketin flask-login. Asennetaan se projektiimme.

(venv) ~/todosovellus$ pip install flask-login
Collecting flask-login
...
Successfully installed flask-login-0.4.1
(venv) ~/todosovellus$ 

Kirjautumistoiminnallisuutta varten tarvitsemme (1) käyttäjän, (2) kirjautumislomakkeen, (3) kirjautumislomakkeen vastaanottavan ja käsittelevän toiminnallisuuden, sekä (4) sovellukseen lisättävän flask-login-toiminnallisuuden.

Luodaan kirjautumistoiminnallisuutta varten kansioon application uusi kansio auth, jonka alle kirjautumiseen liittyvä toiminnallisuus lisätään. Kirjautumislomake sekä muut kirjautumiseen liittyvät HTML-sivut luodaan kansion templates alle luotavaan kansioon auth.

Käyttäjää kuvaava luokka ja tietokantataulu

Luodaan ensin käyttäjää kuvaava luokka User. Luokka luodaan kansion auth alle luotavaan tiedostoon models.py. Jokaisella käyttäjällä on tunnus, tieto luomisesta ja päivittämisestä, nimi, käyttäjänimi, sekä salasana. Näiden lisäksi flask-login määrää (kts. https://flask-login.readthedocs.io/en/latest/#your-user-class), että jokaisella käyttäjälle on metodit get_id, is_active, is_anonymous sekä is_authenticated.

Koska sana user on varattu avainsana myöhemmin käytettävässä PostgreSQL:ssä, asetetaan luokan perusteella luotavan tietokantataulun nimeksi account.

from application import db

class User(db.Model):

    __tablename__ = "account"
  
    id = db.Column(db.Integer, primary_key=True)
    date_created = db.Column(db.DateTime, default=db.func.current_timestamp())
    date_modified = db.Column(db.DateTime, default=db.func.current_timestamp(),
                              onupdate=db.func.current_timestamp())

    name = db.Column(db.String(144), nullable=False)
    username = db.Column(db.String(144), nullable=False)
    password = db.Column(db.String(144), nullable=False)

    def __init__(self, name, username, password):
        self.name = name
        self.username = username
        self.password = password
  
    def get_id(self):
        return self.id

    def is_active(self):
        return True

    def is_anonymous(self):
        return False

    def is_authenticated(self):
        return True

Lisätään luokka myös sovelluksen __init__.py-tiedostossa ladattaviin luokkiin, jolloin luokkaa kuvaava tietokantataulu luodaan sovelluksen käynnistyessä.

from flask import Flask
app = Flask(__name__)
  
from flask_sqlalchemy import SQLAlchemy
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tasks.db"
app.config["SQLALCHEMY_ECHO"] = True
  
db = SQLAlchemy(app)
  
from application import views
  
from application.tasks import models
from application.tasks import views
  
from application.auth import models 
  
db.create_all()

Nyt kun sovellus käynnistetään, tietokantataulu account luodaan tietokantaan ja se löytyy tietokannasta.

(venv) ~/todosovellus$ sqlite3 application/tasks.db
SQLite version 3.11.0 2016-02-15 17:29:24
Enter ".help" for usage hints.
sqlite> .schema
CREATE TABLE account (
    id INTEGER NOT NULL, 
    date_created DATETIME, 
    date_modified DATETIME, 
    name VARCHAR(144) NOT NULL, 
    username VARCHAR(144) NOT NULL, 
    password VARCHAR(144) NOT NULL, 
    PRIMARY KEY (id)
);
CREATE TABLE task (
    id INTEGER NOT NULL, 
    date_created DATETIME, 
    date_modified DATETIME, 
    name VARCHAR(144) NOT NULL, 
    done BOOLEAN NOT NULL, 
    PRIMARY KEY (id), 
    CHECK (done IN (0, 1))
);
sqlite> 

Mikäli tiedostossa tasks.db on paljon turhaa tietoa, tai sen haluaa alustaa uudestaan, onnistuu uudelleen alustaminen poistamalla tiedoston ja käynnistämällä sovelluksen uudestaan.

Lomakkeet ja tietokannan kentät

Huomioi työtä tehdessäsi että SQLAlchemy ainoastaan luo puolestasi taulut joita ei ole olemassa ennestään. Jos teet muutoksia olemassa olevien taulujesi rakenteeseen, joudut poistamaan tietokannan kuten yllä jotta se luotaisiin uudestaan. Huomioi että kun myöhemmin otat käyttöön Postgres-tietokannan Herokussa, joudut tälläisissä tapauksissa poistamaan tietokannan myös Herokusta.

Projektin aikana tietokantaan tehtävät muutokset saa organisoitua järkevämmin jonkun migraatiotyökalun avulla (ks. esim Flask-Migrate). Tätä ei harjoitustyössä vaadita.

Kirjautumislomake

Kirjautumislomaketta varten tarvitaan kolme palaa:

  • Lomaketta kuvaava luokka. Luokka luodaan kansiossa auth olevaan tiedostoon forms.py.
  • Lomakkeen näyttämisen ja lähetyksen vastaanottava toiminnallisuus. Toiminnallisuus luodaan kansiossa auth olevaan tiedostoon views.py.
  • Lomaketta kuvaava HTML-tiedosto. HTML-tiedosto luodaan kansiossa templates olevaan kansioon auth.

Lomaketta kuvaava luokka sisältää kaksi kenttää: tekstikentän käyttäjätunnukselle sekä salasanakentän salasanalle. Kutsutaan lomaketta kuvaavaa luokkaa nimellä LoginForm. Luokka asetetaan kansion auth sisälle tiedostoon forms.py.

from flask_wtf import FlaskForm
from wtforms import PasswordField, StringField
  
class LoginForm(FlaskForm):
    username = StringField("Username")
    password = PasswordField("Password")
  
    class Meta:
        csrf = False

Tehdään seuraavaksi ensimmäinen versio lomakkeen näyttämiseen sekä lähetetyn lomakkeen käsittelyyn tarvittavasta toiminnallisuudesta. Toiminnallisuus asetetaan kansion auth sisällä olevaan (tai sen sisälle luotavaan) tiedostoon views.py.

Toiminnallisuus sisältää sekä lomakkeen näyttämisen että lomakkeen käsittelyn. Mikäli pyyntö osoitteeseen /auth/login on GET-tyyppinen, eli käyttäjä hakee lomaketta, näytetään lomake käyttäjälle. Muulloin käsitellään pyynnössä saatu lomake ja etsitään tietokannasta lomakkeesta saatua käyttäjätunnusta ja salasanaa vastaava käyttäjä. Mikäli käyttäjää ei löydy (if not user), lomake näytetään uudestaan. Lomakkeen näyttämiseen lisätään tällöin myös virheviesti "No such username or password".

Mikäli käyttäjä löytyy, tulostetaan tieto käyttäjän tunnistamisesta ja palataan sivulle sovelluksen etusivulle. Huomaathan, että tämä ei ole vielä koko kirjautumistoiminnallisuus, vaan tässä tehdään kirjautumislomaketta.

from flask import render_template, request, redirect, url_for

from application import app
from application.auth.models import User
from application.auth.forms import LoginForm

@app.route("/auth/login", methods = ["GET", "POST"])
def auth_login():
    if request.method == "GET":
        return render_template("auth/loginform.html", form = LoginForm())

    form = LoginForm(request.form)
    # mahdolliset validoinnit

    user = User.query.filter_by(username=form.username.data, password=form.password.data).first()
    if not user:
        return render_template("auth/loginform.html", form = form,
                               error = "No such username or password")


    print("Käyttäjä " + user.name + " tunnistettiin")
    return redirect(url_for("index"))    

Luodaan vielä lopuksi HTML-tiedosto loginform.html, jota käytetään lomakkeen näyttämiseen. HTML-tiedosto asetetaan templates-kansion sisällä olevaan (tai sisälle luotavaan) kansioon auth.

Tiedoston tulee sisältää LoginForm-luokassa määritellyt kentät username ja password sekä niiden otsikkotekstit. Tämän lisäksi lomakkeessa on mahdollisuus virheviestin näyttämiseen.

{% extends "layout.html" %}

{% block body %}
{{ error }}
<form method="POST" action="{{ url_for('auth_login') }}">
  <table>
    <tr>
      <td>
	{{ form.username.label }}
      </td>
      <td>
	{{ form.username }}
      </td>
    </tr>
    <tr>
      <td>
	{{ form.password.label }}
      </td>
      <td>
	{{ form.password }}
      </td>
    </tr>
    <tr>
      <td colspan="2">
	<input type="submit" value="Login"/>
      </td>
    </tr>
</form>
{% endblock %}

Käynnistetään sovellus ja tarkastellaan lomakkeen toimintaa. Tarkastellaan lomakkeen toimintaa.

Lomaketta ei löydy.

 

Lomaketta ei löydy.

Virhe on tuttu. Emme ole lisänneet lomakkeen näyttämisestä vastuussa olevaa kansion auth views-tiedoston lataamista osaksi sovelluksen käynnistymistä. Muokataan __init__.py-tiedostoa siten, että tiedosto sisältää myös viitteen auth-kansion views.py-tiedostoon.

from flask import Flask
app = Flask(__name__)

from flask_sqlalchemy import SQLAlchemy
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tasks.db"
app.config["SQLALCHEMY_ECHO"] = True

db = SQLAlchemy(app)

from application import views

from application.tasks import models
from application.tasks import views

from application.auth import models
from application.auth import views

db.create_all()

Nyt lomake löytyy ja käyttäjä voi yrittää kirjautua. Tällä hetkellä tietokannassa ei ole yhtäkään käyttäjää, joten "kirjautuminen" epäonnistuu aina.

Kirjautumisyritys. Käyttäjää ei löydy.

 

Lisätään tietokantaan käyttäjä. Käyttäjän nimeksi tulee "hello world", käyttäjätunnukseksi "hello" ja salasanaksi "world".

SQLite version 3.11.0 2016-02-15 17:29:24
Enter ".help" for usage hints.
sqlite> INSERT INTO account (name, username, password) VALUES ('hello world', 'hello', 'world');
sqlite> 

Nyt "kirjautuminen" edellä mainitulla tunnuksella onnistuu.

Konkreettinen kirjautuminen ja käyttäjän tunnistaminen

Todellisuudessahan edellä toteutettu kirjautumistoiminnallisuus on oikeastaan vain lomake, johon lisättyjä tietoja etsitään tietokannasta. Lisätään sovellukseen kirjautumiseen vaadittava toiminnallisuus.

Konfiguraatiot

Kirjautumistoiminnallisuutta varten tarvitaan kirjautumisten käsittelyyn tarvittava LoginManager-olio, joka kytketään osaksi sovellusta. Tämän lisäksi oliolle tulee määritellä kirjautumisen käsittelyyn käytettävä funktio (login_view), jotta sovellus osaa ohjata pyynnön kyseiselle funktiolle tarvittaessa.

Lisätään tarvittu toiminnallisuus __init__.py-tiedostoon. Tiedosto on kokonaisuudessaan nyt seuraavanlainen.

# flask-sovellus
from flask import Flask
app = Flask(__name__)

# tietokanta
from flask_sqlalchemy import SQLAlchemy
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tasks.db"
app.config["SQLALCHEMY_ECHO"] = True

db = SQLAlchemy(app)

# oman sovelluksen toiminnallisuudet
from application import views

from application.tasks import models
from application.tasks import views

from application.auth import models
from application.auth import views

# kirjautuminen
from application.auth.models import User
from os import urandom
app.config["SECRET_KEY"] = urandom(32)

from flask_login import LoginManager
login_manager = LoginManager()
login_manager.init_app(app)

login_manager.login_view = "auth_login"
login_manager.login_message = "Please login to use this functionality."

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(user_id)

# luodaan taulut tietokantaan tarvittaessa
db.create_all()

Kirjautumiseen käytettävä funktio load_user lataa tietokannasta käyttäjän avaimen perusteella.

Kirjautuminen

Lisätään kirjautumislomakkeen käsittelyyn konkreettinen kirjautuminen. Kirjautuminen tapahtuu flask_login-pakkauksesta löytyvällä login_user-funktiolla.

from flask import render_template, request, redirect, url_for
from flask_login import login_user
  
from application import app
from application.auth.models import User
from application.auth.forms import LoginForm

@app.route("/auth/login", methods = ["GET", "POST"])
def auth_login():
    if request.method == "GET":
        return render_template("auth/loginform.html", form = LoginForm())

    form = LoginForm(request.form)
    # mahdolliset validoinnit

    user = User.query.filter_by(username=form.username.data, password=form.password.data).first()
    if not user:
        return render_template("auth/loginform.html", form = form,
                                error = "No such username or password")


    login_user(user)
    return redirect(url_for("index"))    

Lisätään näkymiin tieto kirjautuneesta käyttäjästä ja vaihtoehtoisesti pyydetään käyttäjää kirjautumaan. Tieto kirjautumisesta (tai sen puutteesta) löytyy käyttäjää kuvaavasta muuttujasta current_user. Muokataan layout.html-tiedostoa seuraavasti.

  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
      <title>TodoApplication</title>
    </head>
    
    <body>
      {% if current_user.is_authenticated %}
      <p>
	Kirjautunut nimellä {{ current_user.name }}
      </p>
      {% else %}
      <a href="{{ url_for('auth_login') }}">Kirjaudu</a>
      {% endif %}
      
      <ul>
	<li><a href="{{ url_for('tasks_index') }}">List tasks</a></li>
	<li><a href="{{ url_for('tasks_form') }}">Add a task</a></li>
      </ul>
      
      {% block body %}
      <p>
	Content.
      </p>
      {% endblock %}
    </body>
  </html>  

Nyt jokaisella sivulla on pääsy kirjautumislomakkeeseen ja käyttäjä voi kirjautua sovellukseen.

Sivulla näkyy kirjautumislomake ja kirjautuminen onnistuu.

Uloskirjautuminen

Yllä luotu kirjautumistoiminnallisuus on kiva, mutta siitä puuttuu uloskirjautuminen. Uloskirjautumisen lisääminen on suoraviivaista. Lisätään ensin layout.html-tiedostoon linkki uloskirjautumiseen -- sovitaan tässä, että uloskirjautumista hoitaa funktio, jonka nimi on auth_logout.

...
  
<body>
{% if current_user.is_authenticated %}
<p>
Kirjautunut nimellä {{ current_user.name }}. <a href="{{ url_for('auth_logout') }}">Kirjaudu ulos</a>
</p>
{% else %}
<a href="{{ url_for('auth_login') }}">Kirjaudu</a>
{% endif %}

...

Nyt kirjautuneelle käyttäjälle näkyy linkki, jossa lukee "Kirjaudu ulos".

Sivulla näkyy uloskirjautumislinkki.

Lisätään vielä kirjautumiseen liittyviä toiminnallisuuksia sisältävän kansion auth alla olevaan tiedostoon views.py konkreettinen uloskirjautumistoiminnallisuus. Uloskirjautuminen tapahtuu flask_login-kirjaston tarjoamalla logout_user-funktiolla.

from flask import render_template, request, redirect, url_for
from flask_login import login_user, logout_user

from application import app, db
from application.auth.models import User
from application.auth.forms import LoginForm

@app.route("/auth/login", methods = ["GET", "POST"])
def auth_login():
    # aiemmin toteutettu kirjautumistoiminnallisuus
  
    return redirect(url_for("index"))    


@app.route("/auth/logout")
def auth_logout():
    logout_user()
    return redirect(url_for("index"))    

Nyt käyttäjä voi sekä kirjautua että kirjautua ulos. Kirjautuneen käyttäjän tiedot löytyvät palvelimelta current_user-nimisestä muuttujasta.

Selkokieliset salasanat

Esimerkissä tietokantaan tallennettiin selkokielisiä salasanoja. Yleisesti ottaen tämä on erittäin huono tapa. Tässä materiaalissa asiaan ei paneuduta tarkemmin, mutta kannattaa etsiä lisää tietoa aiheesta avainsanoilla flask login bcrypt password.

Uusien käyttäjien luominen

Mikäli sovellukseen haluaa luoda uusia käyttäjiä, tulee ne luoda joko komentoriviltä, tai käyttäjien hallintaan tulee luoda erillinen näkymä. Uusien käyttäjien luominen tai käyttäjien muokkaaminen toimii samalla tavalla kuin tehtävien luominen.

Polkujen suojaaminen

Kun kirjautumistoiminnallisuus on käytössä, sovelluksen polkuja voidaan suojata @login_required-määreellä. Määreen tulee olla määreen @app.route jälkeen.

Määritellään tehtävien lisäämiseen ja muokkaamiseen vaatimus kirjautumisesta. Muokataan kansiossa tasks olevaa tiedostoa views.py seuraavasti. Alla funktiot tasks_form, tasks_set_done, sekä tasks_create vaativat käyttäjän kirjautumista.

from flask import render_template, request, redirect, url_for
from flask_login import login_required

from application import app, db
from application.tasks.models import Task
from application.tasks.forms import TaskForm


@app.route("/tasks/", methods=["GET"])
def tasks_index():
    return render_template("tasks/list.html", tasks = Task.query.all())

  
@app.route("/tasks/new/")
@login_required
def tasks_form():
    return render_template("tasks/new.html", form = TaskForm())

@app.route("/tasks/<task_id>", methods=["POST"])
@login_required
def tasks_set_done(task_id):

    t = Task.query.get(task_id)
    t.done = True
    db.session().commit()
  
    return redirect(url_for("tasks_index"))

@app.route("/tasks/", methods=["POST"])
@login_required
def tasks_create():
    form = TaskForm(request.form)
  
    if not form.validate():
        return render_template("tasks/new.html", form = form)

  
    t = Task(form.name.data)
    t.done = form.done.data
  
    db.session().add(t)
    db.session().commit()
  
    return redirect(url_for("tasks_index"))

Nyt sovellus ohjaa käyttäjän kirjautumissivulle mikäli käyttäjä hakee kirjautumista vaativaa osoitetta.

Käyttäjälle näytetään kirjautumissivu mikäli käyttäjä pyrkii osoitteeseen, mihin hänellä ei ole oikeutta.

Viitteet taulujen välillä

Tarkastellaan tässä viitteitä taulujen välillä. Osoitteessa http://flask-sqlalchemy.pocoo.org/2.3/models/ oleva opas on tässä(kin) hyvä.

Luodaan esimerkinomaisesti sovellukseen toiminnallisuus, missä jokainen tehtävä liittyy tiettyyn käyttäjään.

Käyttäjään liittyvät tehtävät

Muokataan sovellusta seuraavaksi siten, että jokainen luotu tehtävä liittyy aina johonkin käyttäjään. Tätä toiminnallisuutta varten tarvitsemme tehtävä-käsitteeseen viitteen käyttäjään, sekä käyttäjään tiedon siitä, että käyttäjään liittyy tehtäviä.

Muokataan ensin kansiossa tasks olevaa tiedostoa models.py siten, että tehtävään liittyy aina käyttäjä.

from application import db
  
class Task(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    date_created = db.Column(db.DateTime, default=db.func.current_timestamp())
    date_modified = db.Column(db.DateTime, default=db.func.current_timestamp(),
    onupdate=db.func.current_timestamp())

    name = db.Column(db.String(144), nullable=False)
    done = db.Column(db.Boolean, nullable=False)

    account_id = db.Column(db.Integer, db.ForeignKey('account.id'),
                           nullable=False)

    def __init__(self, name):
        self.name = name
        self.done = False

Muokataan tämän jälkeen kansiossa auth olevaa tiedostoa models.py siten, että jokaiseen käyttäjään liitetään käyttäjän tehtävät.

from application import db

class User(db.Model):

    __tablename__ = "account"
  
    id = db.Column(db.Integer, primary_key=True)
    date_created = db.Column(db.DateTime, default=db.func.current_timestamp())
    date_modified = db.Column(db.DateTime, default=db.func.current_timestamp(),
                              onupdate=db.func.current_timestamp())

    name = db.Column(db.String(144), nullable=False)
    username = db.Column(db.String(144), nullable=False)
    password = db.Column(db.String(144), nullable=False)

    tasks = db.relationship("Task", backref='account', lazy=True)
  
    def __init__(self, name):
        self.name = name
        
    def get_id(self):
        return self.id
  
    def is_active(self):
        return True

    def is_anonymous(self):
        return False

    def is_authenticated(self):
        return True

Poistetaan käytössä oleva tietokanta (tiedosto tasks.db kansiossa application), jotta tietokantataulun luodaan uudestaan. Nyt kun sovellus on käynnistetty kertaalleen, tiedosto tasks.db on luotu uudestaan.

Tietokannan rakenne on nyt seuraava.

SQLite version 3.11.0 2016-02-15 17:29:24
Enter ".help" for usage hints.
sqlite> .schema
CREATE TABLE account (
    id INTEGER NOT NULL, 
    date_created DATETIME, 
    date_modified DATETIME, 
    name VARCHAR(144) NOT NULL, 
    username VARCHAR(144) NOT NULL, 
    password VARCHAR(144) NOT NULL, 
    PRIMARY KEY (id)
);
CREATE TABLE task (
    id INTEGER NOT NULL, 
    date_created DATETIME, 
    date_modified DATETIME, 
    name VARCHAR(144) NOT NULL, 
    done BOOLEAN NOT NULL, 
    account_id INTEGER NOT NULL, 
    PRIMARY KEY (id), 
    CHECK (done IN (0, 1)), 
    FOREIGN KEY(account_id) REFERENCES account (id)
);
sqlite> 

Taululla task on nyt viiteavain account_id, joka viittaa käyttäjiä sisältävään account-tauluun.

Viitteen lisääminen: Uuden tehtävän luominen

Mikäli (kun) poistit edellä tietokannan, luo uusi käyttäjä tietokantaan ennen tämän askeleen testaamista. Aiemmin luotu 'hello world'-käyttäjä on poistunut tietokannan mukana.

Muokataan tehtävän lisäämiseen tarkoitettua toiminnallisuutta siten, että luotavaan tehtävään kytketään aina käyttäjä. Kirjautunut käyttäjä on muuttujassa current_user, ja kirjautuneen käyttäjän pääavain on käyttäjän id-muuttujassa. Muokataan kansiossa tasks olevaa views.py-tiedostoa siten, että uuden tehtävän luominen asettaa myös käyttäjän luotavalle tehtävälle.

from flask import render_template, request, redirect, url_for
from flask_login import login_required, current_user

from application import app, db
from application.tasks.models import Task
from application.tasks.forms import TaskForm


@app.route("/tasks/", methods=["GET"])
def tasks_index():
    return render_template("tasks/list.html", tasks = Task.query.all())

  
@app.route("/tasks/new/")
@login_required
def tasks_form():
    return render_template("tasks/new.html", form = TaskForm())

  
@app.route("/tasks/<task_id>/", methods=["POST"])
@login_required
def tasks_set_done(task_id):

    t = Task.query.get(task_id)
    t.done = True
    db.session().commit()
  
    return redirect(url_for("tasks_index"))

  
@app.route("/tasks/", methods=["POST"])
@login_required
def tasks_create():
    form = TaskForm(request.form)
  
    if not form.validate():
        return render_template("tasks/new.html", form = form)
  
    t = Task(form.name.data)
    t.done = form.done.data
    t.account_id = current_user.id
  
    db.session().add(t)
    db.session().commit()
  
    return redirect(url_for("tasks_index"))

Nyt tehtävän luomisen yhteydessä (funktio tasks_create) luotavalle tehtävälle asetetaan tällä hetkellä kirjautuneen käyttäjän tunnus.

Kehitys- ja tuotantoympäristöt

Sovelluksemme toimii tällä hetkellä paikallisesti SQLiteä käyttäen. Lisätään sovellukseen vielä PostgreSQL-tuki, mikä mahdollistaa Herokun tarjoaman PostgreSQL:n käytön.

Asennetaan sovellukseen ensin PostgreSQL-tietokannanhallintajärjestelmän käyttöön tarvittava ajuri psycopg2.

(venv) ~/todosovellus$ pip install psycopg2
Collecting psycopg2
...
Successfully installed psycopg2-2.7.4
(venv) ~/todosovellus$ 

Luodaan tämän jälkeen uusi versio requirements.txt-tiedostosta, jota Heroku käyttää riippuvuuksien lataamiseen.

(venv) ~/todosovellus$ pip freeze | grep -v pkg-resources > requirements.txt
(venv) ~/todosovellus$ 

Lisätään tämän jälkeen muutetut tiedostot versionhallintaan -- tätä ei erikseen ohjeisteta.

Lisätään seuraavaksi sovelluksen käyttöön tieto siitä, että sovellus on Herokussa. Tämä tapahtuu luomalla Herokuun ympäristömuuttuja HEROKU. Ympäristömuuttujan lisääminen tapahtuu komentoriviltä komennolla heroku config:set HEROKU=1 -- tässä komento heroku on osa Herokun komentorivityökaluja.

(venv) ~/todosovellus$ heroku config:set HEROKU=1
Setting HEROKU and restarting ⬢ sovellus... done
HEROKU: 1
(venv) ~/todosovellus$ 

Tarkastellaan nyt sovellusta ja katsotaan onko sovelluksella olemassa jo tietokanta. Tämä onnistuu kirjautumisyrityksellä Herokun PostgreSQL-tietokantaan.

(venv) ~/todosovellus$ heroku pg:psql
▸    ⬢ sovellus has no databases
(venv) ~/todosovellus$ 

Yllä oleva viesti antaa ymmärtää, että tietokantaa ei ole. Lisätään Herokuun tietokanta.

(venv) ~/todosovellus$ heroku addons:add heroku-postgresql:hobby-dev
...
(venv) ~/todosovellus$ 

Nyt sovellukselle on Herokussa tietokanta. Heroku antaa tietokannan ympäristömuuttujassa DATABASE_URL. Muokataan sovelluksen __init__.py-tiedostoa siten, että Herokussa ollessamme käytetään Herokun tietokantaan, muulloin omaa tietokantaamme. Tämän lisäksi tietokantataulujen luominen tapahtuu vain kerran (try-catch).

from flask import Flask
app = Flask(__name__)

from flask_sqlalchemy import SQLAlchemy

import os

if os.environ.get("HEROKU"):
    app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DATABASE_URL")
else:
    app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tasks.db"    
    app.config["SQLALCHEMY_ECHO"] = True

  
db = SQLAlchemy(app)

from application import views

from application.tasks import models
from application.tasks import views

from application.auth import models
from application.auth import views


# login
from application.auth.models import User
from os import urandom
app.config["SECRET_KEY"] = urandom(32)

from flask_login import LoginManager
login_manager = LoginManager()
login_manager.init_app(app)

login_manager.login_view = "auth_login"
login_manager.login_message = "Please login to use this functionality."

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(user_id)


try: 
    db.create_all()
except:
    pass

Nyt sovelluksen lisääminen Herokuun onnistuu, ja sovellus toimii melkein. Sovelluksessa ei ole yhtäkään käyttäjää, joten kirjautumisesta ei ole juurikaan hyötyä. Lisätään sovellukseen vielä käyttäjä.

(venv) ~/todosovellus$ heroku pg:psql
--> Connecting to osoite
psql (9.5.12, server 10.3 (Ubuntu 10.3-1.pgdg14.04+1))
WARNING: psql major version 9.5, server major version 10.
Some psql features might not work.
SSL connection (protocol: TLSv1.2, cipher: jotain, bits: 256, compression: off)
Type "help" for help.

sovellus::DATABASE=> \dt
List of relations
Schema |  Name   | Type  |     Owner      
--------+---------+-------+----------------
public | account | table | wkxxmbgyjastfs
public | task    | table | wkxxmbgyjastfs
(2 rows)

sovellus::DATABASE=> INSERT INTO account (name, username, password) VALUES ('hello world', 'hello', 'world');
INSERT 0 1
sovellus::DATABASE=> SELECT * FROM account;
id | date_created | date_modified |    name     | username | password 
----+--------------+---------------+-------------+----------+----------
1 |              |               | hello world | hello    | world
(1 row)

sovellus::DATABASE=> 
sovellus::DATABASE=> \q
(venv) ~/todosovellus$ 

Nyt tietokannassa on käyttäjä, jonka käyttäjätunnus on hello ja salasana world.

Kun kokeilemme sovellusta verkossa, siihen saattaa liittyy pieniä omituisuuksia. Tarkista, että sovelluksen Procfile sisältää toisen osan ehdotetut parametrit.

(venv) ~/todosovellus$ cat Procfile
web: gunicorn --preload --workers 1 application:app
(venv) ~/todosovellus$ 

Mikäli sovellus ei vieläkään toimi, Herokuun saa logituksen päälle suoraviivaisesti. Muokkaa run.py-tiedostoa siten, että se sisältää seuraavat app.logger-rivit.

from application import app

app.logger.addHandler(logging.StreamHandler(sys.stdout))
app.logger.setLevel(logging.ERROR)

if __name__ == '__main__':
    app.run(debug=True)

Eräs tyypillinen virhe liittyy kirjautumiseen. Mikäli olet kirjautunut ja käynnistät sovelluksen uudelleen, kirjautumistiedot katoaa. Sovellusta kannattaa testata selaimen anonyymimodessa (esim. Chromen "New incognito window"). Kun käynnistät sovelluksen uudelleen, sulje myös selaimen anonyymi-ikkuna ja avaa se uudelleen. Tämän jälkeen kirjaudu sovellukseen ja käytä sitä.

Sisällysluettelo