- 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.
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.
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.
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.
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.
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 tiedostoonforms.py
. -
Lomakkeen näyttämisen ja lähetyksen vastaanottava toiminnallisuus. Toiminnallisuus luodaan kansiossa
auth
olevaan tiedostoonviews.py
. -
Lomaketta kuvaava HTML-tiedosto. HTML-tiedosto luodaan kansiossa
templates
olevaan kansioonauth .
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.
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.
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.
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".
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.
Esimerkissä tietokantaan tallennettiin selkokielisiä salasanoja. Yleisesti ottaen tämä on erittäin huono tapa. Palaamme myöhemmin hieman järkevämpään tapaan -- halutessasi voit nyt jo 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.
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ä.