Blow Up by Black Swan

Python-Flask で作る Web アプリケーション(チュートリアル編5)

チュートリアルに関する記事の5本目です。

参考サイト: Flask – Tutorial(公式ドキュメント)

7. ブログのblueprint

認証用のblueprintを記述した時と同じ方法でブログのblueprintも記述することができます。ブログページでは、全ての投稿を一覧で表示し、かつログインユーザは新規投稿を作成でき、投稿者は自身の投稿を編集、削除することができます。

それぞれのview関数を実装したように、サーバを走らせ続け、変更するごとにブラウザで変更点を確認します。

7-1. blueprint

まず、blueprintを定義し、アプリケーションファクトリーに登録します。

# flaskr/blog.py

from flask import (
    Blueprint, flash, g, redirect, render_template, request, url_for
)
from werkzeug.exceptions import abort

from flaskr.auth import login_required
from flaskr.db import get_db

bp = Blueprint('blog', __name__)

blogをインポートし、app.register_blueprint()を使ってファクトリーにblueprintを登録します。ファクトリー関数の最後の方で、appをreturnする前の部分に新しいコードを記述します。

#flaskr/__init__.py

def create_app():
    app = ...
    # existing code omitted

    from . import blog
    app.register_blueprint(blog.bp)
    app.add_url_rule('/', endpoint='index')

    return app

認証用のblueprntと異なり、ブログblueprintはprefixを持ちません。そのため、インデックスは/となり、ブログの作成は/createとなります。ブログはFlaskrの主要機能ですので、ブログのインデックスがメインインデックスとなることは理にかなった仕様となります。

しかし、下で定義されるindexの名称はblog.indexとなります。認証用view関数のいくつかは、まっさらなindexを参照しています。app.add_url_rule()はエンドポイントindexとURL/を関連付けるめため、url_for('index')url_for('blog.index')は両方とも同じ意味となり、URL/を生成します。

別のアプリケーションを作る際にブログblueprintにurl_prefixを付与し、アプリケーションファクトリー内でindexview関数は別に定義することもあります。このとき、indexblog.indexは異なるものとなります。

7-2. view関数:Index

インデックスでは、全ての投稿を表示し、最新の投稿が一番上にくる仕様となります。userテーブルの投稿者情報をpostテーブルの検索で利用できるようにするため、SQLクエリのJOINが使われています。

# flaskr/blog.py

@bp.route('/')
def index():
    db = get_db()
    posts = db.execute(
        'SELECT p.id, title, body, created, author_id, username'
        ' FROM post p JOIN user u ON p.author_id = u.id'
        ' ORDER BY created DESC'
    ).fetchall()
    return render_template('blog/index.html', posts=posts)
# flaskr/templates/blog/index.html

{% extends 'base.html' %}

{% block header %}
  <h1>{% block title %}Posts{% endblock %}</h1>
  {% if g.user %}
    <a class="action" href="{{ url_for('blog.create') }}">New</a>              {# createページへのリンクを加える #}
  {% endif %}
{% endblock %}

{% block content %}
  {% for post in posts %}
    <article class="post">
      <header>
        <div>
          <h1>{{ post['title'] }}</h1>
          <div class="about">by {{ post['username'] }} on {{ post['created'].strftime('%Y-%m-%d') }}</div>
        </div>
        {% if g.user['id'] == post['author_id'] %}
          <a class="action" href="{{ url_for('blog.update', id=post['id']) }}">Edit</a>
        {% endif %}
      </header>
      <p class="body">{{ post['body'] }}</p>
    </article>
    {% if not loop.last %}                                                      {# それぞれの投稿の後にラインを表示する #}
      <hr>
    {% endif %}
  {% endfor %}
{% endblock %}

ユーザがログインしているときには、headerブロックにcreateページへのリンクボタンが加わります。ログインユーザがブログの投稿者である場合、その投稿にはupdateページにつながる”Edit”ボタンが表示されます。loop.lastJinja for loops内で利用可能な特別な変数です。各投稿を視覚的に区別するために最後の投稿を除くそれぞれの投稿の後にラインを表示するために利用されています。

※ 投稿がある場合は”Posts”の下に表示される

tutorial8

※ ログインされている場合 => 投稿作成用の”New”ボタンが見える

tutorial9

7-3. view関数:Create

createview関数はregisterview関数と類似しています。フォームの表示、ポストされたデータの検証、その投稿のデータベースへの追加またはエラー表示と行った処理が行われます。

前に記述したlogin_requiredデコレータはblogのview関数で使われます。ユーザはblog関連ページにアクセスするにはログインしている必要があり、そうでない場合ログインページにリダイレクトされます。

# flaskr/blog.py

@bp.route('/create', methods=('GET', 'POST'))
@login_required                                     # ログイン判定するデコレータ
def create():
    if request.method == 'POST':
        title = request.form['title']
        body = request.form['body']
        error = None

        if not title:
            error = 'Title is required.'

        if error is not None:
            flash(error)
        else:
            db = get_db()
            db.execute(
                'INSERT INTO post (title, body, author_id)'
                ' VALUES (?, ?, ?)',
                (title, body, g.user['id'])
            )
            db.commit()
            return redirect(url_for('blog.index'))

    return render_template('blog/create.html')
# flaskr/templates/blog/create.html

{% extends 'base.html' %}

{% block header %}
  <h1>{% block title %}New Post{% endblock %}</h1>
{% endblock %}

{% block content %}
  <form method="post">
    <label for="title">Title</label>
    <input name="title" id="title" value="{{ request.form['title'] }}" required>
    <label for="body">Body</label>
    <textarea name="body" id="body">{{ request.form['body'] }}</textarea>
    <input type="submit" value="Save">
  </form>
{% endblock %}
tutorial10

7-4. view関数: Update

updatedeleteはともに、ユーザのidを指定してpostを取得し、記事の投稿者がログインユーザかチェックされる必要があります。コードの重複を避けるためpostを取得する関数を定義し、それぞれのview関数で呼び出すという方法をとっています。

# flaskr/blog.py

def get_post(id, check_author=True):
    post = get_db().execute(
        'SELECT p.id, title, body, created, author_id, username'
        ' FROM post p JOIN user u ON p.author_id = u.id'
        ' WHERE p.id = ?',
        (id,)
    ).fetchone()

    if post is None:
        abort(404, "Post id {0} doesn't exist.".format(id))

    if check_author and post['author_id'] != g.user['id']:
        abort(403)

    return post

abort()は、HTTPのステータスコードを返す特別な例外を発生させます。エラーを表示する際にメッセージを表示させることもできます。メッセージを指定しない場合、デフォルトメッセージの”Not Found”を意味する404や”Forbidden”を意味する403が使われます(401は”Unauthorized”を意味しますが、このステータスコードを返す代わりにこのアプリケーションではログインページへのリダイレクト処理が行われています)。

check_author引数は、この関数が投稿者を確認せずともpostを取得できるようにするためのものです。この引数は、一つのページでそれぞれの投稿を表示するview関数を書くときに便利で、ユーザ側から見てもその投稿を編集することにはならないため問題とはなりません。

#flaskr/blog.py

@bp.route('/<int:id>/update', methods=('GET', 'POST'))
@login_required
def update(id):                                            # routeデコレータの<int:id>がidに納まる
    post = get_post(id)

    if request.method == 'POST':
        title = request.form['title']
        body = request.form['body']
        error = None

        if not title:
            error = 'Title is required.'

        if error is not None:
            flash(error)
        else:
            db = get_db()
            db.execute(
                'UPDATE post SET title = ?, body = ?'
                ' WHERE id = ?',
                (title, body, id)
            )
            db.commit()
            return redirect(url_for('blog.index'))

    return render_template('blog/update.html', post=post)

今まで書いてきたview関数と異なり、update関数は引数idを取ります。これはルートデコレータの<int:id>に対応します。実際のURLは/1/updateのように表示されます。Flaskは「1」をキャッチし、int型であることを確認し、引数idに引き渡します。int:と指定せず<id>とすると、「1」は文字列型になります。該当するアップデートページへのURLを生成するためにはurl_for関数にidを渡す必要がありますが、そのためにはurl_for('blog.update', id=post['id'])を満たす必要があります。このコードはindex.htmlファイルに記述されています。

createupdateはとても似ていますが、大きな違いはupdateview関数はSQLクエリでpostオブジェクトとINSERTの代わりにUPDATEクエリを使うところです。よりクレバーなリファクタリングをすれば、これらアクションを1つのview関数とテンプレートにすることもできますが、チュートリアルでは明確にするため分けています。

# flaskr/templates/blog/update.html

{% extends 'base.html' %}

{% block header %}
  <h1>{% block title %}Edit "{{ post['title'] }}"{% endblock %}</h1>
{% endblock %}

{% block content %}
  <form method="post">                                                                               {# 編集内容を表示 #}
    <label for="title">Title</label>
    <input name="title" id="title"
      value="{{ request.form['title'] or post['title'] }}" required>
    <label for="body">Body</label>
    <textarea name="body" id="body">{{ request.form['body'] or post['body'] }}</textarea>
    <input type="submit" value="Save">
  </form>
  <hr>
  <form action="{{ url_for('blog.delete', id=post['id']) }}" method="post">                           {# 削除ボタン #}
    <input class="danger" type="submit" value="Delete" onclick="return confirm('Are you sure?');">
  </form>
{% endblock %}

このテンプレートは2つのフォームを持ちます。最初のフォームは、現在のページ(/<id>/update)に編集したデータを送ります。もう1つのフォームはボタンだけを含み、deleteview関数に送るようaction属性で指定されている。このボタンは、送信前の確認ダイアログを表示するためJavaScriptを利用しています。

パターン{{ request.form['title'] or post['title'] }}は、フォームの中で表示されるデータを指定するために使われます。フォームが送信されなかった場合、オリジナルデータが表示されますが、無効なフォームデータが投稿された場合、ユーザがエラーを修正できるようにするため、代わりにrequest.formが使われます。requestはテンプレート内で自動で使うことができるもう1つの変数です。

7-5. view関数: Delete

deleteview関数は自身のテンプレートを持ちません。削除ボタンはupdate.htmlの一部であり、URL/<id>/deleteへとアクセスします。テンプレートはないので、POSTメソッドだけを処理し、indexページにリダイレクトされます。

# flaskr/blog.py

@bp.route('/<int:id>/delete', methods=('POST',))
@login_required
def delete(id):
    get_post(id)
    db = get_db()
    db.execute('DELETE FROM post WHERE id = ?', (id,))
    db.commit()
    return redirect(url_for('blog.index'))

・投稿がある場合のインデックス画面

tutorial11

・”Edit”ボタンを押した時のアップデート画面

tutorial12

・”delete”ボタンを押した時の確認画面

tutorial13

これでアプリケーションに関するコードの記述が完了しました。ただし、プロジェクトが完了するにはするべきことがまだ残っています。段落

チュートリアル6