下町エンジニアの雑多ブログ

東京の端っこでITエンジニアやってます。テックブログを中心に書いていきたいと思います

Flask入門~公式ドキュメントを読み解く~ Day 6 ~

f:id:usktkt:20181019225022p:plain

前回の内容

前回の記事では、MVTモデルのテンプレートについてみていきました。具体的には、画面描画のためのhtmlをJinjaテンプレートを使って実装し、CSSでスタイルを整えました。

前回までの記事で、基本的なMVTモデルについて全て説明したことになります。なので、今回はそれらの総復習ということで、今回のアプリの中心機能であるブログ表示/投稿/更新/削除機能を一気に作っていきます。

ブログ各機能の実装

Blueprint

まずはじめにブログ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__)

ただし、認証用Blueprintと1箇所だけ違う点としては、ブログBlueprintはurl_prefixを指定していません。認証用Blueプリントでは、url_prefixにauthを設定していたため、/auth/registerや/auth/loginのようにアクセスしていましたが、ブログBlueprintでは/createや/updateのように各ビューにアクセスします。 これは、ブログ機能がこのアプリケーションのメイン機能であるためこのような設定としています。

ブログBlueprintの実装が完了したら、これをアプリケーションに登録していきます。

__init__.pyの最後のreturnの前に以下を入力します。

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

    return app    # 最終行

またここで1点注意なのですが、認証用Blueprintの時にはなかったapp.add_url_ruleがあります。app.add_url_ruleは、endpoint引数で指定した値とurl_forの引数が一致する場合、URLを第一引数に変換します。すなわち、url_for('index')は'/'に変換されます。url_for('index')はloginビューとlogoutビュー中に登場しています。以下の3つは表現は違いますが、indexページにリダイレクトするという挙動は同じになります。

redirect(url_for('index'))    # url_for('index')で生成されるURLを'/'に変換
redirect(url_for('blog.index'))    # url_for('index')で生成されるURLを'/'に変換
redirect('/')    # 直接'/'にリダイレクト

トップ画面表示機能

続いてインデックスビューとインデックステンプレートを作成します。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)

indexビューではDBからユーザーの記事を抽出し、抽出結果をindexテンプレートに渡しています。

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>
  {% 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 %}

特段目新しい箇所はないかと思いますが、1箇所あげるとすれば、loop.lastでしょうか。これは、最後のループか否かを判定してくれます。ループ処理がまだ続くのであれば、記事と記事の間に水平線を引いて見やすくしています。

投稿機能

続いて投稿機能です。createビューを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')

ここでは@login_requiredを使用しています。これは、認証用Blueprintで作成したlogin_requiredメソッドを呼び出しています。ブログ記事の投稿なので、登録したユーザーでログインしている必要があります。ビューの前に@login_requiredとしておくことで、ビューメソッドに入る前にlogin_requiredを呼び出し、ログインしていなければ、loginビューにリダイレクトさせることができます。@login_requiredを使用するためには、flask.authモジュールのlogin_requiredをインポートしておく必要があることに注意してください。

ブログ投稿ページとして、template/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 %}

更新機能

次に更新機能です。投稿した記事を修正できるようにします。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

@bp.route('/<int:id>/update', methods=('GET', 'POST'))
@login_required
def update(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)

まずはじめにget_postメソッドを定義しています。これは引数に投稿idを受け、そのidの投稿を取得します。ここでabortメソッドを使用していますが、これはHTTPステータスの例外をあげることができます。引数で受けたidで投稿が取得できない場合は404エラー(“Not Found”を表す)、投稿のauthor_idとログインユーザーのidが一致しない場合は403エラー("Forbidden” : 閲覧禁止を表す)を返します。

get_postメソッドの下にupdateビューを定義していますが、updateビューはこれまでのビューとは異なり、引数をとります。引数のidには@bp.route('/<int:id>/update', methods=('GET', 'POST'))のidが入ります。そのため、updateビューを呼び出すためのURLは"/1/update"のようにidを渡す必要があることに注意してください。これを実現しているのが、index.htmlの<a class="action" href="{{ url_for('blog.update', id=post['id']) }}">Edit</a>の部分です。

続いてcreateテンプレートです。templates/blog/create.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つのフォームで形成されています。1つ目のフォームは投稿の編集機能で、タイトル、本文を入力します。2つ目のフォームはdeleteビューへのPOSTアクションボタンを作成します。このボタンは、送信する前に確認ダイアログを表示するためにJavaScriptを使用しています。

<textarea name="body" id="body">{{ request.form['body'] or post['body'] }}</textarea>{{ request.form['body'] or post['body'] }}では、フォームに本文が入力されていれば、request.form['body'] が、何も入力されていなければpost['body']が表示されます。

削除機能

最後の最後にdeleteビューを作成します。delete機能はテンプレートを持たないので、ビューのみとなります。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'))

まとめ

ここまででflaskrアプリケーションの全機能が完成です。長かったですね。 基本的な使用法は学べたので、これらの知識を使って実際にアプリケーションを作ってみます。