akatak’s blog

プログラム初心者の50代ビジネスマンがセカンドキャリアを目指して働きながらPythonを中心に独学しています。自らの覚え書きや成果物、感じたことなどを脈絡もなく書き連ねるブログです。

FlaskとSQLAlchemyで読書記録Webアプリを作る(3)

前回まで2回分が前置きとなってしまいました。失礼しました。今回は前置きなく、早速スクリプトを記述していきます。

読書記録Webアプリの骨組みの作成①(データベースを利用せず)

まずは、データベースを使わずに、取り敢えず、読書記録一覧を表示するインデックスページを作りましょう。読書記録のデータは、データベースから取得するのではなく、スクリプトの中で与えるようにします。

前回ご紹介した最小限のプログラムHello.pyを、今後の展開を考慮して構成を以下の通り分解するイメージです。最終的に前回ご紹介の全体構成につなげていく予定です。

books.py
web/
    __init__.py
    views.py
    templates/
               base.html
               index.html

これは、books.pyを実行すれば、読書記録のリストがホームページ表示されるという、ただ、それだけのアプリです。

具体的に、各スクリプトを記述していきましょう。

# books.py
from web import app

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

このbooks.pyが置いてあるディレクトリで、python books.pyを実行すると、app.runにて、デバッグモード(debug=True)にてアプリが立ち上がります。その実行するアプリは、webディレクトリの__init__.pyからインポートしておきます(from web import app)。

# web/__init__.py

from flask import Flask
# from flask_sqlalchemy import SQLAlchemy
# from flask_migrate import Migrate

app = Flask(__name__)

# db = SQLAlchemy(app)       # SQLAlchemyを利用する場合
# migrate = Migrate(app, db) # Flask-Migrateを利用する場合

from web import views

ここがメインのスクリプトです。今回は、データベースを利用しないため、データベースに関連する行をコメントアウト(#)しています。そうすると、ここでは、flaskからFlask クラスをインポートし、Flaskインスタンスを生成するだけです。前回、Hello World!を表示するために__init__.pyに記載した、URLを示すデコレータと、そのURLを指定された時に実行される関数の組み合わせは、views.pyとして別ファイルに記述します。そして、ここでは、views.pyをインポートしておきます。

# web/views.py
from web import app
from flask import render_template

@app.route('/')
def index():
    books = [{'title':'坂の上の雲', 'author':'司馬遼太郎', 'genre':'小説', 'date':'2018-06-01'},
             {'title':'竜馬がゆく', 'author':'司馬遼太郎', 'genre':'小説', 'date':'2017-12-31'},
             {'title':'ノーサイド・ゲーム', 'author':'池井戸潤', 'genre':'小説', 'date':'2019-06-30'}]
    return render_template('index.html', books=books)

ここで、URLを示すデコレータと、そのURLを指定された時に実行される関数の組み合わせを作成します。ルートディレクトhttp://127.0.0.1:5000/をブラウザのURL欄に入力すると、index関数が呼び出されます。

そして、index.htmlを表示します。その際に引数で指定したbooks(この場合、読書記録が保持されたリスト)もindex.htmlに渡されます。

<!-- web/templates/index.html -->

{% extends "base.html" %}
{% block content %}
<br>
<table class="table">
  <thead class="thead-light">
    <tr>
      <th scope="col">書籍名</th>
      <th scope="col">著者</th>
      <th scope="col">ジャンル</th>
      <th scope="col">読了日</th>
    </tr>
  </thead>

  {% for book in books%}
      <tbody>
        <tr>
          <td> {{ book.title }} </td>
          <td> {{ book.author }} </td>
          <td> {{ book.genre }} </td>
          <td> {{ book.date }} </td>
        </tr>
      </tbody>
  {% endfor %}
</table>

{% endblock %}

{}で囲んで記述しているのは、flaskをインストールした際に、同時にインストールされているjinja2と呼ばれるテンプレートエンジンです(jinja-wikipedia)。html文書上で、for文やif文その他をpythonのように使える優れものです。作者はflaskと同じ作者ですので、flaskとの親和性は高いのではないでしょうか。

index.htmlが開かれると、同時に渡されたbooksリストから、{% for book in books %}{% endfor %}に囲まれた部分により、1つずつ辞書を取得し、テーブルに展開していくようになっています。

なお、{% extends "base.html" %}により、{% block content %}``{% endblock %}で囲まれた部分以外は、以下のbase.htmlに記載されたものがindex.htmlにも適用されます。

<!-- web/templates/base.html -->

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>読書記録データベース</title>
<!-- ここからはBootstrap4を利用するために、Bootstrapのダウンロードサイト(https://getbootstrap.com/docs/4.3/getting-started/download/)から"BootstrapCDN"を以下にコピペします. -->
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
<!-- ここまで -->
    <link rel="stylesheet" href="{{ url_for('static',filename='css/style.css')}}">
  </head>
  <body>
    <div class="container">
      <br>
      <h4>読書記録データベース</h4>
      <nav class="navbar navbar-expand-lg navbar-dark bg-dark">

      <a class="navbar-brand" href="{{ url_for('index') }}">ホーム</a>
      <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>

        <div class="collapse navbar-collapse" id="navbarSupportedContent">
          <ul class="navbar-nav mr-auto">
            <li class="nav-item active">
              <a class="nav-link" href="#">読書記録の入力</a>
            </li>
          </ul>
        </div>
      </nav>
    {% block content %}

    {% endblock %}
    </div>

  </body>
</html>

Bootsrapとは、Webページを効率よく開発するためのWebフレームワークで、Webページでよく使われるフォーム、メニュー、ボタンなどのテンプレートが用意されています。これらを使うことにより、デザインを気にすることなく(Bootstrapに任せて)、プログラムの他のところに注力できるメリットがあります。デザインはシンプルですが、そこそこ統一感のあるものができます。

これを使わない手はありませんので、base.htmlにおいて、Bootstrap4のサイトから、BootstrapCDNの必要な箇所をコピペしています。

そして、ナビゲーションバー(メニューバー)をclass="navbar"等で設定しています。

さて、ここでまで出来たところで、 python books.pyを実行します。すると、以下の画面が表示されました。取り敢えず、データベースのない基本構成はできました。

f:id:akatak:20190704215826p:plain

読書記録Webアプリの骨組みの作成②(データベースを利用)

次に、pythonに標準で添付されているデータベースsqlite3を利用できるように、スクリプトを変更していきます。全体構成は以下の通り。config.py, models.py, forms.py, register_book.htmlを追加します。また、__init__.py, views.pyを修正します

books.py
config.py  #追加
web/
    __init__.py  #修正
    views.py     #修正
    models.py    #追加
    forms.py     #追加
    templates/
               base.html
               index.html
               register_book.html  #追加

__init__.pyは、以下の通り修正します。前回コメントアウトしていた部分を全て使えるように#を削除します。

# web/__init__.py
from flask import Flask
from config import Config
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

app = Flask(__name__)
app.config.from_object(Config)
db = SQLAlchemy(app)       
migrate = Migrate(app, db) 

from web import views

config.pyにて、sqlite3データベースの名前や場所等を設定します。同時に、__init__.pyにおいてfrom config import Configを追加します。

# config.py

import os

basedir = os.path.abspath(os.path.dirname(__file__))

class Config(object):
    SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(os.path.abspath(os.path.dirname(__file__)), 'web/app.db')
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SECRET_KEY = 'my_secret_key'

SQLALCHEMY_DATABASE_URIにて、app.dbという名称のsqlite3データベースをwebディレクトリ下に置くことを設定しています。また、SQLALCHEMY_TRACK_MODIFICATIONSをTrueに設定すると、オブジェクトの変更等がある度にメッセージが出されますので、ここではFalseに設定します。

さらに、ここでは、SECRET_KEYとして任意の文字列を設定しています。flask-wtfでは、CSRF(*)対策が可能ですが、それを利用するために、SECRET_KEYを設定しておく必要があります。

(*)クロスサイトリクエストフォージェリの略であり、Webアプリケーションの脆弱性を利用したサイバー攻撃の一種のこと。

さて、データベースの設定を行います。SQLAlchemyでは、クラスにおいてテーブル名、カラム名、データ型を定義しておくと、自動的に各データベースに変換するメソッドがあります。今回は、読書記録を保存するBookクラスを以下の通り設定します。ここでは、flask-sqlalchemyを利用していますので、__init__.pyからdbのみをインポートしておけば足ります。

# web/
from web import db

class Book(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(64), index=True)
    author = db.Column(db.String(64), index=True)
    genre = db.Column(db.String(64), index=True)
    date = db.Column(db.Date)
    
    def __repr__(self):
        return '<Book {}>'.format(self.title)

さらに、読書記録を入力するページ(register_book.html)を作成しますので、入力フォームもクラスとして設定します。wtformsモジュールから必要なフィールドStringField, DateField, SubmitFieldをインポートして利用します。wtformsを利用するとvalidatorsにより、入力制限をかけることも可能です。ここでは、titleauthorは必ず入力が必要とするDataRequiredを入力制限として設定しています。

# web/forms.py

from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, DateField
from wtforms.validators import DataRequired

class BookForm(FlaskForm):
    title = StringField('書籍', validators=[DataRequired()])
    author = StringField('著者', validators=[DataRequired()])
    genre = StringField('ジャンル')
    date = DateField('読了日', format="%Y-%m-%d")
    submit = SubmitField('登録')

さて、views.pyを修正していきましょう。

@app.route('/register')以下を追加します。アドレス欄に/registerを入力等すると、register_book関数(読書記録の登録機能)が呼び出されます。

# web/views.py
from web import app, db
from web.forms import BookForm
from web.models import Book
from flask import render_template, redirect, url_for

@app.route('/')
def index():
    books = Book.query.all()
    return render_template('index.html', books=books)
    
@app.route('/register', methods=['GET','POST'])
def register_book():
    form = BookForm()
    
    if form.validate_on_submit():
        book = Book(title=form.title.data, author=form.author.data, genre=form.genre.data, date=form.date.data)
        db.session.add(book)
        db.session.commit()
        return redirect(url_for('index'))
    return render_template('register_book.html', form=form)

register_book関数においては、まず、BookFormクラスをインスタンス化します。そして、validate_on_submitメソッドにより、POSTリクエストがあるかどうか、また、有効なものかどうかをチェックします。そうであれば、入力されたtitle, author, genre, dateをもとに作成したBookオブジェクトをデータベースにデータとして追加(db.session.add(book))します。さらに、コミット(db.session.commit())することにより、データベースへの登録が確定します。

そして、url_for('index')により、index関数を発動するURLを取得し、そのURLにredirect(遷移・移動)します。POSTリクエストがない、もしくは無効であれば、register_book.htmlを表示します。

def index():以下も、データベースがある前提での記述に修正します。 ここでは、データベースから読書記録の全データを取得して、一覧表示にするだけです。 したがって、前段で記述したリストbooks = [{...},{...},{...}]books = Book.query.all()に修正します。

最後に、register_book.htmlを以下の通り、作成します。 Bootstrap4の機能・デザインを利用するために、class="..."を指定しています。また、{{ form.hidden_tag()}}によりCSRF対策として自動的にトークンを発行します。

# web/templates/register_book.html
{% extends "base.html" %}
{% block content %}
  <div class="container">
    <form class="form-group" method="POST">
      {{ form.hidden_tag() }}
      <br>
      <div class="form-group">
        {{ form.title.label(class="form-control-label") }}
        {{ form.title(class="form-control form-control-lg") }}
      </div>
      <div class="form-group">
        {{ form.author.label(class="form-control-label") }}
        {{ form.author(class="form-control form-control-lg") }}
      </div>
      <div class="form-group">
        {{ form.genre.label(class="form-control-label") }}
        {{ form.genre(class="form-control form-control-lg") }}
      </div>
      <div class="form-group">
        {{ form.date.label(class="form-control-label") }}
        {{ form.date(class="form-control form-control-lg", placeholder="2019/5/1") }}
      </div>
      <div class="form-group">
        {{form.submit(class="btn btn-primary")}}
      </div>

    </form>
  </div>
{% endblock %}

なお、base.htmlのリンク<a class="nav-link" href="#">読書記録の入力</a>#{{url_for('register_book')}} で置き換えておきます。

以上で、データベースへの登録のみ可能な読書記録Webアプリができました。修正、削除機能がないので不十分ですが、取り敢えず、実行してみましょう。

ターミナル画面でexport FLASK_APP=books.pyexport FLASK_ENV=developmentを入力します。これらにより、ターミナル画面でflask runと入力すると、デバッグモードでbooks.pyが実行できるようになります。

また、以下の一連の操作により、データベースへのテーブルの作成等が簡単に行えるようになります。

flask runを実行する前に、データベースを作成しておきましょう。

$ flask db init

このコマンドにより、books.pyと同じレベルにmigrationフォルダが作成されます。このフォルダ下には変更履歴等が保存されていきます。

$ flask db migrate -m"first setup"

このコマンドは、データベース移行のスクリプトを作成しますが、データベースはこの時点では変更されません。-mの後の" "の間には任意の文字列を入力します。今回は、データベースの初回セッティングのためfirst setupとでも書いておきます。

データベースに変更を加えるには、以下のコマンドを実行する必要があります。

$ flask db upgrade

すると、それぞれ以下のような表示が出ます。

$ flask db init
 Creating directory /Users/tak/Desktop/flask-reading-records-v1/migration
s
  ... done
  Creating directory /Users/tak/Desktop/flask-reading-
  records-v1/migrations/versions ... done
  Generating /Users/tak/Desktop/flask-reading-
  records-v1/migrations/script.py.mako ... done
  Generating /Users/tak/Desktop/flask-reading-records-v1/migrations/env.py
  ... done
  Generating /Users/tak/Desktop/flask-reading-records-v1/migrations/README
  ... done
  Generating /Users/tak/Desktop/flask-reading-
  records-v1/migrations/alembic.ini ... done
records-v1/migrations/alembic.ini ... done
  Please edit configuration/connection/logging settings in
  '/Users/tak/Desktop/flask-reading-records-v1/migrations/alembic.ini'
  before proceeding.

$ flask db migrate -m"first setp"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'book'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_book_author'  on '['author']'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_book_genre' on '['genre']'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_book_title' on '['title']' 
 Generating /Users/tak/Desktop/flask-reading-records-v1/migrations/versions/91eea8077bdd_first_setp.py ... done

$ flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade  -> 91eea8077bdd, first setp

無事、データベースが初期化できたようです。 それでは、books.pyを実行しましょう。ターミナルからflask runでも実行できますし、また、python books.pyでも実行できます。

$ flask run
 * Serving Flask app "books" (lazy loading)
 * Environment: development
 * Debug mode: on
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 282-501-988

http://127.0.0.1:5000/をブラウザのURL欄にコピペします。

f:id:akatak:20190706112758p:plain

インデックスページが表示されました。まだ、データはありませんので、表題以外は何も表示されません。メニュー画面の"読書記録の入力"をクリックしてみます。

f:id:akatak:20190706113109p:plain

入力画面が表示されました。早速、入力してみましょう。

f:id:akatak:20190706113232p:plain

登録をクリックします。ついでに、もう1件入力してみます。以下の通り、2件とも無事、表示できました。

f:id:akatak:20190706113525p:plain

今回は以上です。入力だけできても、修正もできない、削除もできないとなると困りますよね。次回以降、CRUD操作全体(Create, Read, Update, Delete)を網羅していきたいと思います。

なお、今回作成したスクリプトをご参考までに以下にアップロードしております。flask-reading-records-v0がデータベースがない版、flask-reading-records-v1がデータベースあり版です。

GitHub - tak-akashi/flask-reading-records: Web application for reading records using flask