Python Flask Tutorial で読書ログアプリーケーションを作ってみた 後編
Blueprints と Views
Blueprints をつくる
Blueprints は関連した Views や他のコードのまとまりを整理するやり方で、 Views や他のコードを直接アプリケーションに紐づけるのではなく、 Blueprints としてまとめる。そして Blueprints をアプリケーションに紐づける。今回は2つの Blueprints (認証機能と読書ログのポスト機能)をつくる。それぞれの Blueprints は別個のモジュールにつくります。まずは認証から。
readinglog/auth.py
#! /usr/bin/env python # -*- coding: utf8 -*- import functools from flask import ( Blueprint, flash, g, redirect, render_template, request, session, url_for ) from werkzeug.security import check_password_hash, generate_password_hash from readinglog.db import get_db bp = Blueprint("auth", __name__, url_prefix="/auth")
authという名前の Blueprint を作成している。アプリケーションオブジェクトと同様に Blueprint は自身がどこに定義されているかを第二引数に渡す。 url_prefix キーワード引数を使い Blueprint に紐づくURLにプレフィックスを追加できる。
application factory からの Blueprint のインポートと紐づけは app.register_blueprint() を使う。 application factory のアプリケーションを返す前にコードを追加します。
readinglog/__init__.py
from . import auth app.register_blueprint(auth.bp) return app
auth Blueprint には新規ユーザー登録とログイン/ログアウトのビューを実装していきます。
新規ユーザー登録ビュー
/auth/register という URL に対してのビューとなります。
readinglog/auth.py
@bp.route("/register", methods=("GET", "POST")) def register(): if request.method == "POST": username = request.form["username"] password = request.form["password"] db = get_db() error = None if not username: error = "ユーザー名が必要です" elif not password: error = "パスワードが必要です" elif db.execute( "SELECT id FROM user WHERE username = ?", (username,) ).fetchone() is not None: error = "ユーザー名 {} はすでに登録されています".format(username) if error is None: db.execute( "INSERT INTO user (username, password) VALUES (?, ?)", (username, generate_password_hash(password)) ) db.commit() return redirect(url_for("auth.login")) flash(error) #エラーメッセージを保持 return render_template("auth/register.html")
ログインビュー
readinglog/auth.py
@bp.route("/login", methods=("GET", "POST")) def login(): if request.method == "POST": username = request.form["username"] password = request.form["password"] db = get_db() error = None user = db.execute( "SELECT * FROM user WHERE username = ?", (username,) ).fetchone() if user is None: error = "ユーザー名が不正です" elif not check_password_hash(user["password"], password): error = "パスワードが不正です" if error is None: session.clear() session["user_id"] = user["id"] return redirect(url_for("index")) flash(error) return render_template("auth/login.html")
ユーザーIDをセッションに登録することで以降のリクエストでログイン状態に応じて利用できるようにしています。
readinglog/auth.py
@bp.before_app_request def load_logged_in_user(): user_id = session.get("user_id") if user_id is None: g.user = None else: g.user = get_db().execute( "SELECT * FROM user WHERE id = ?", (user_id,) ).fetchone()
bp.before_app_request() でどのURLリクエストに関わらずビューの呼び出し前に動く関数を作ることができます。
ログアウトビュー
ユーザーIDをセッションから消去します。
readinglog/auth.py
@bp.route("/logout") def logout(): session.clear() return redirect(url_for("index"))
他のビューにおける認証の必要性
読書ログの登録、変更、削除といった機能はユーザーがログインしている必要があるのでデコレーターを使うことでこれをチェックします。
readinglog/auth.py
def login_required(view): @functools.wraps(view) def wrapped_view(**kwargs): if g.user is None: return redirect(url_for("auth.login")) return view(**kwargs) return wrapped_view
このデコレーターは元のビューをラップした新しいビューを返す。新しいビューはログイン状態をチェックしてログインしていなければログインページへリダイレクトする。ログインしていれば元のビューが呼ばれて処理は継続される。
テンプレート
テンプレートファイルはパッケージ内の templates ディレクトリに保存する。
Flask ではテンプレートのレンダリングに Jinja テンプレートライブラリを使う。
テンプレートの HTML 中で {{ と }} で囲まれた箇所に python のコードを書けば保持している値に変換されて出力される。
また {% と %} で if 文や for 文のような制御表現も可能。
ベースレイアウト
アプリケーション中のページの共通部分をベーステンプレートにまとめ、それぞれのテンプレートエンジンはベーステンプレートを継承して異なる部分を上書きすることができるので便利。
readinglog/templates/base.html
<!doctype html> <title>{% block title %}{% endblock %} - 読書ログ</title> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <nav> <h1>読書ログ</h1> <ul> {% if g.user %} <li><span>{{ g.user['username'] }}</span> <li><a href="{{ url_for('auth.logout') }}">ログアウト</a> {% else %} <li><a href="{{ url_for('auth.register') }}">ユーザー登録</a> <li><a href="{{ url_for('auth.login') }}">ログイン</a> {% endif %} </ul> </nav> <section class="content"> <header> {% block header %}{% endblock %} </header> {% for message in get_flashed_messages() %} <div class="flash">{{ message }}</div> {% endfor %} {% block content %}{% endblock %} </section>
g オブジェクトはテンプレート中で自動的に利用できる。url_for() 関数も自動的に利用できる。
get_flashed_messages() 関数はビュー中で flush() 関数で示したエラーメッセージをループで返してくれる。
3つのブロック表現がありそれぞれ他のテンプレートで上書きされることになる。
- {% block title %} はブラウザのタブとウィンドウのタイトルで表現されるタイトルを変化させる。
- {% block header %} はtitle と似ているがページで表現されるタイトルを変化させる。
- {% block content %} はそれぞれのページのコンテンツの位置。
ベーステンプレートは templates ディレクトリに直接置き、 Blueprint で使用されるテンプレートは Blueprint と同じ名前のディレクトリを作りその中に置く。
新規ユーザー登録用テンプレート
readinglog/templates/auth/register.html
{% extends 'base.html' %} {% block header %} <h1>{% block title %}ユーザー登録{% endblock %}</h1> {% endblock %} {% block content %} <form method="post"> <label for="username">ユーザー名</label> <input name="username" id="username" required> <label for="password">パスワード</label> <input type="password" name="password" id="password" required> <input type="submit" value="新規登録"> </form> {% endblock %}
{% extends 'base.html' %} でベーステンプレートからブロック部分を置換することを表現できる。
{% block title %} を {% block header %} 中に書くことでタイトルブロックをセットすると同時にヘッダブロック中にその値をセットできる。そうするとウィンドウとページで同じタイトルを2回書かずに表現できる。
ログイン用テンプレート
タイトルとサブミットボタンを除き登録テンプレートと同じ。
readinglog/templates/auth/login.html
{% extends 'base.html' %} {% block header %} <h1>{% block title %}ログイン{% endblock %}</h1> {% endblock %} {% block content %} <form method="post"> <label for="username">ユーザー名</label> <input name="username" id="username" required> <label for="password">パスワード</label> <input type="password" name="password" id="password" required> <input type="submit" value="ログイン"> </form> {% endblock %}
静的ファイル
Flask では静的ファイルを置くディレクトリへのパスを返してくれる static ビューが自動的に利用できる。
CSS や JavaScript やロゴイメージなど静的ファイルは readinglog/static ディレクトリに置くことで url_for('static', filename='...') のように参照できる。
今回はチュートリアルの CSS をそのまま利用させていただきました。
Bookshelf Blueprint
Blueprint を定義して application factory に登録
readinglog/book_shelf.py
#! /usr/bin/env python # -*- coding: utf8 -*- from flask import ( Blueprint, flash, g, redirect, render_template, request, url_for ) from werkzeug.exceptions import abort from readinglog.auth import login_required from readinglog.db import get_db import datetime import requests bp = Blueprint("book_shelf", __name__)
app.register_blueprint() で登録する。
readinglog/__init__.py
from . import book_shelf app.register_blueprint(book_shelf.bp) app.add_url_rule("/", endpoint="index") return app
auth Blueprint と異なり url_prefix を持たないので index ビューは / create ビューは /create のような URL になる。
Bookshelf Blueprint はメイン機能なのでインデックスはメインインデックスと同義にする。
しかし index ビューは book_shelf.index にように定義することになる。
app.add_url_rule() を使って index というエンドポイント名を / に紐づけて url_for('index') も url_for('blog.index') も同じく / URL を生成するようにする。
Index
readinglog/book_shelf.py
def get_book_info(posts): isbn_lis = [str(post["isbn"]) for post in posts] isbns = ",".join(isbn_lis) url = "https://api.openbd.jp/v1/get" params = {"isbn":isbns} r = requests.get(url, params=params) json_data = r.json() book_info = {} for book_data in json_data: summary = book_data["summary"] book_info[int(summary["isbn"])] = summary return book_info status_dic = { 0:"未読", 1:"読書中", 2:"読了", } evaluation_dic = { None:"なし", 1:"☆", 2:"☆☆", 3:"☆☆☆", } @bp.route("/") @login_required def index(): db = get_db() user_id = g.user["id"] query = """ SELECT b.id,b.owner_id, b.created, b.isbn, b.status, b.evaluation, b.finished, u.username FROM book_shelf b INNER JOIN user u ON b.owner_id = u.id WHERE b.owner_id = {} ORDER BY b.created DESC """.format(user_id) posts = db.execute(query).fetchall() if posts: book_info = get_book_info(posts) else: book_info = {} return render_template("bookshelf/index.html", posts=posts, book_info=book_info, status_dic=status_dic, evaluation_dic=evaluation_dic)
readinglog/template/bookshelf/index.html
{% extends "base.html" %} {% block header %} <h1>{% block title %}最新の本棚{% endblock %}</h1> {% if g.user %} <a class='action' href='{{ url_for("book_shelf.add") }}'>新規登録 </a> {% endif %} {% endblock %} {% block content %} {% for post in posts %} <article class='post'> <header> <div> <h1>{{ book_info[post["isbn"]]["title"] }}</h1> <div><img src='{{ book_info[post["isbn"]]["cover"] }}' width='10%' height='10%'></div> <div class='about'>ISBN {{ post["isbn"] }} {{ book_info[post["isbn"]]["publisher"] }} {{ book_info[post["isbn"]]["author"] }}</div> <div class='about'>{{ post["username"] }} が {{ post["created"].strftime("%Y-%m-%d") }} に追加</div> </div> {% if g.user["id"] == post["owner_id"] %} <a class='action' href='{{ url_for("book_shelf.update", id=post["id"]) }}'>更新</a> {% endif %} </header> <p class='body'>ステータス:{{ status_dic[post["status"]] }} 評価:{{ evaluation_dic[post["evaluation"]] }}</p> </article> {% if not loop.last %} <hr> {% endif %} {% endfor %} {% endblock %}
loop.last は Jinja for loops 中で使える特殊な変数。
Create
readinglog/book_shelf.py
@bp.route("/add", methods=("GET", "POST")) @login_required def add(): if request.method == "POST": isbn = request.form["isbn"] error = None if not isbn: error = "ISBNコードが必要です" if error is not None: flash(error) else: db = get_db() query = """ INSERT INTO book_shelf (owner_id, isbn) VALUES ({}, {}) """.format(g.user["id"], isbn) db.execute(query) db.commit() return redirect(url_for("book_shelf.index")) return render_template("bookshelf/add.html")
readinglog/template/bookshelf/add.html
{% extends "base.html" %} {% block header %} <script type=text/javascript src='{{url_for("static", filename="jquery-3.5.1.min.js")}}'></script> <script type=text/javascript src='{{url_for("static", filename="get_book.js")}}'></script> <h1>{% block title %}新規登録{% endblock %}</h1> {% endblock %} {% block content %} <form method='post'> <label for='isbn'>ISBNコード</label> <input name='isbn' id='isbn' value='{{ request.form["isbn"] }}' required> <input type='submit' value='登録'> </form> {% endblock %}
Update
まずは post されたコンテンツを取得するビューを書く。
readinglog/book_shelf.py
def get_post(id, check_owner=True): query = """ SELECT b.id, b.owner_id, b.created, b.isbn, b.status, b.evaluation, b.finished, u.username FROM book_shelf b INNER JOIN user u ON b.owner_id = u.id WHERE b.id = {0} """.format(id) post = get_db().execute(query).fetchone() if post is None: abort(404, "指定の id {0} は存在しません".format(id)) if check_owner and post["owner_id"] != g.user["id"]: abort(403) return post
abort() 関数は HTTP ステータスコードを返す例外を送出する。
readinglog/book_shelf.py
@bp.route("/<int:id>/update", methods=("GET", "POST")) @login_required def update(id): post = get_post(id) if request.method == "POST": status = request.form["status"] evaluation = request.form["evaluation"] if status == 2: finished = datetime.datetime.now().strftime("'%Y-%m-%d %H:%M:%S'") else: finished = "NULL" db = get_db() query = """ UPDATE book_shelf SET status = {}, evaluation = {}, finished = {} WHERE id = {} """.format(status, evaluation, finished, id) db.execute(query) db.commit() return redirect(url_for("book_shelf.index")) book_info = get_book_info([post]) return render_template("bookshelf/update.html", post=post, book_info=book_info, status_dic=status_dic, evaluation_dic=evaluation_dic)
readinglog/template/bookshelf/update.html
{% extends "base.html" %} {% block header %} <h1>{% block title %}「{{ book_info[post["isbn"]]["title"] }}」{% endblock %}</h1> {% endblock %} {% block content %} <div><img src='{{ book_info[post["isbn"]]["cover"] }}' width='10%' height='10%'></div> <form method='post'> <label for='isbn'>ISBN:{{ post["isbn"] }}</label> <label for='created'>登録日時:{{ post["created"] }}</label> <label for='status'>ステータス</label> <select name='status'> {% for i in range(0, 3) %} <option value='{{ i }}' {% if i == post["status"] %} selected {% endif %}>{{ status_dic[i] }}</option> {% endfor %} </select> <label for='evaluation'>評価</label> <select name='evaluation'> {% for i in range(0, 4) %} <option value='{{ i }}' {% if i == post["evaluation"] %} selected {% endif %}>{{ evaluation_dic[i] }}</option> {% endfor %} </select> <input type='submit' value='更新'> </form> <hr> <form action='{{ url_for('book_shelf.delete', id=post['id']) }}' method='post'> <input class='danger' type='submit' value='削除' onclick='return confirm("削除してよろしいですか?");'> </form> {% endblock %}
request はテンプレート中で自動的に使える変数。
Delete
readinglog/book_shelf.py
@bp.route("/<int:id>/delete", methods=("POST",)) @login_required def delete(id): get_post(id) db = get_db() query = """ DELETE FROM book_shelf WHERE id= {0} """.format(id) db.execute(query) db.commit() return redirect(url_for("book_shelf.index"))