akatak blog

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

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

こんにちは。早速ですが、読書記録Webアプリの機能アップを更に図っていきましょう。

今回の主要テーマはページネーション(pagination)です。日本語ではページ割りというのかもしれません。アプリが現在のままだとページの概念がないので、読書記録を登録していくと、どんどん行が下に伸びていってしまいます。

それでは困りますよね。一定の行数を超えたら次のページに移動しないと表示されないようにする必要があります。このような時に利用するのがPagination機能で、実はFlask-SQLAlchemyには、予めこのPagination機能が備わっています。

Pagination機能を利用する

具体的には、Queryオブジェクトのpaginateメソッドを利用します。 例えば、Bookクラスの全てのデータをデータベースから取り出すには

books = Book.query.all()

と記述しました。pagination機能を使うには

books = Book.query.paginate(page=1, per_page=10, error_out=False).items

のように、all()paginate().itemsで置き換えます。

paginateメソッドには、上記のように引数を3つ渡します。

page=はページ番号です。1ページから始まります。per_page=は1ページあたりの表示件数です。error_out=は、Trueに設定すると、データのあるページ数を超えてページを設定するとエラーを返します。Falseに設定しておくと、データのあるページ数を超えてページを指定すると、エラーではなく、空のリストが返されます。

paginateメソッドにより、paginationオブジェクトが返されます。上記のitemsはpaginationオブジェクトの属性の一つで、リクエストしたページに属するデータのリストが入っています。上記の場合、1ページ目に属する10件のデータリストとなります。

その他にも使える属性があり、以下のFlask-SQLAlchemyのドキュメントに記載されています(英語ですが)。 https://flask-sqlalchemy.palletsprojects.com/en/2.x/api/

さて、実際にスクリプトを変更していきましょう。 まず、1ページ当たりの表示件数を、使い回しができるように、configクラスで設定しておきましょう。

class Config(object):
    # ...
    ITEMS_PER_PAGE = 3

1ページ当たりの表示件数を3件と少なくしているのは、あまりたくさんデータを入力しなくても、ページネーション機能の確認を容易にするためです。

次に、views.pyを修正していきます。

先ほどは、books = Book.query.paginate(page=1, per_page=10, error_out=False).itemsとしましたが、これだと読書リストをhtmlに渡すだけになりますので、ここでは、Book.query.paginate(page=1, per_page=10, error_out=False)までのPaginationオブジェクトをhtmlに渡すようにします。 そうすることにより、Paginationオブジェクトを渡されたhtml側でPaginationオブジェクトの属性を利用することで、各種処理をできるようになります。

@books.route('/', methods=['GET','POST'])
def index():

    books = Book.query.order_by(Book.date.desc()).paginate(page=1, per_page=app.config['ITEMS_PER_PAGE'], error_out=False)
    authors = db.session.query(Author).join(Book, Book.author_id == Author.id).all()
    return render_template('books/index.html', books=books, authors=authors)

変更前は、books = Book.query.all()で得られた読書リストが渡されていましたので、{% for book in books %}...{% endfor %}でしたが、今回はPaginationオブジェクトが渡されていますので、{% for book in books.items %}...{% endfor %}となっています。

ここまでは1ページ目ですので、2ページ目以降に対応する関するを作成します。1ページ目のページネーションにより表示されたページに飛べるようにします。ページ数がクリックされますので、そのページをpage_numとして関数に引き渡します。

@books.route('/books/pages/<int:page_num>', methods=['GET','POST'])
def index_pages(page_num):

    books = Book.query.order_by(Book.date.desc()).paginate(page=page_num, per_page=app.config['ITEMS_PER_PAGE'], error_out=False)
    authors = db.session.query(Author).join(Book, Book.author_id == Author.id).all()
    return render_template('books/index.html', books=books, authors=authors)

表示する側の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>
      <th scope="col">オススメ度</th>
    </tr>
  </thead>

  {% for book in books.items %}
      <tbody>
        <tr>
          <td> <a href="{{ url_for('books.each_book', id=book.id) }}"> {{ book.title }} </a></td>
          {% for author in authors %}
            {% if author.id == book.author_id %}
              <td> <a href="{{ url_for('authors.each_author', id=book.author_id)}}"> {{ author.name }}</a></td>
            {% endif %}
          {% endfor %}
          <td>{{ book.genre }}</td>
          <td> {{ book.date }}</td>
          <td> {{ book.recommended }} </td>
        </tr>
      </tbody>
  {% endfor %}
</table>
<p align="right">合計 {{ books.total }} 冊 </p>

<nav aria-label="Page navigation example">
  <ul class="pagination justify-content-center">
     {% for page in books.iter_pages() %}
      {% if page %}
        {% if page != books.page %}
            <li class="page-item"><a class="page-link" href="{{ url_for('books.index_pages', page_num=page) }}">{{ page }}</a></li>
        {% else %}
            <li class="page-item active"><a class="page-link">{{ page }}</a></li>
        {% endif %}
      {% else %}
        <span> ... </span>
      {% endif %}
    {% endfor %}
  </ul>
</nav>

そして、Paginationの部分は、{% for page in books.iter_pages() %}...{% endfor %}と、Paginationオブジェクト"books"のイテレータiter_pages()を利用しています。ただし、ページが増えてくると、ページ数を返さない場合がありますので、{% if page %}{% else %}{% endif %}にて...を表示するようにしています。

また、その上の行で<p align="right">合計 {{ books.total }} 冊 </p>とあり、Paginationオブジェクトの属性total(全体件数)を利用しています。

views.pyから渡すものをPaginationオブジェクトにすると、html側でいろいろと利用でき、便利ですね。

さて、同様に著者についてもページネーションを適用します。authors/views.pyを以下の通りに変更します。

@authors.route('/authors/index')
def index_author():
    authors = Author.query.order_by(Author.name).paginate(page=1, per_page=app.config['AUTHORS_PER_PAGE'], error_out=False)
    return render_template('authors/index_author.html', authors=authors)

@authors.route('/authors/index/<int:page_num>', methods=['GET','POST'])
def index_author_pages(page_num):
    authors = Author.query.order_by(Author.name).paginate(page=page_num, per_page=app.config['AUTHORS_PER_PAGE'], error_out=False)
    return render_template('authors/index_author.html', authors=authors)

templates/authors/index.htmlも、同様に以下の通り変更します。

{% extends "base.html" %}
{% block content %}
 <br>
 <h4>著者一覧</h4>

 <br>
 <table class="table">
   <thead class="thead-light">
     <tr>
       <th scope="col">著者</th>
       <th scope="col">説明</th>
     </tr>
   </thead>

  {% for author in authors.items %}
    <tbody>
      <tr>
        <td> <a href="{{ url_for('authors.each_author', id=author.id)}}">{{ author.name }}</a> </td>
        <td>{{ author.extras }}</td>
      </tr>
    </tbody>
  {% endfor %}
  </table>

<nav aria-label="Page navigation example">
  <ul class="pagination justify-content-center">
    {% for page in authors.iter_pages(left_edge=2, left_current=2, right_current=5, right_edge=2) %}
      {% if page %}
        {% if page != authors.page %}
            <li class="page-item"><a class="page-link" href="{{ url_for('authors.index_author_pages', page_num=page) }}">{{ page }}</a></li>
        {% else %}
            <li class="page-item active"><a class="page-link">{{ page }}</a></li>
        {% endif %}
      {% else %}
        <span> ... </span>
      {% endif %}
    {% endfor %}
  </ul>
</nav>

{% endblock %}

読書記録を表示させるとこんな感じ。

f:id:akatak:20190718224547p:plain

うまく表示できましたでしょうか。PaginationでもBootstrapを利用し、見栄えを良くしています。

修正時のボタン表示の変更

読書記録や著者の登録内容を変更する際に表示されるボタンが「登録」「削除」ではやはり違和感がありますよね。内容を修正する際のボタンは「修正」とするのが良いかなと思います。

forms.pyに以下を追加します。BookFormとAuthorFormをもとにBookUpdateFormとAuthorUpdateFormを作成します。

class BookUpdateForm(BookForm):
    submit = SubmitField('修正')

class AuthorUpdateForm(AuthorForm):
    submit = SubmitField('修正')

そして、views.pyのupdate関数のform=BookForm()とform=AuthorForm()をBookUpdateForm()とAuthorUpdateForm()に修正します。

本日のアップデートは以上です。 次回は読書記録として入力したい「おすすめ度」「コメント」欄を追加するとともに「検索機能」を導入することで、アプリの最終化を図りたいと思います。

ここまでのアプリもアップロードしておきます。ご参考まで。

github.com

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

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

さて、前回までに作成しましたアプリの機能アップを図っていきましょう。

まず、著者をデータベースとして登録し、読書記録データの登録時に著者リストから選択できるようにします。

その場合、著者についても、読書記録と同様にCRUD機能を網羅しておいた方が良さそうです。

ところが、読書記録と同じように、同じフォルダに著者のCRUD用ファイルを作っていくと、似たようなファイルが量産され、混乱することが容易に想像できます。その際に利用すると便利なのが、Flaskに予め用意されているBlueprint機能です。

Blueprint機能を導入する

Blueprintを利用すると、アプリケーションのview.pyを機能毎に分けて管理ができるようになります。例えば、今回のように、読書記録(Book)を登録・閲覧・修正・削除する機能と、著者を登録・閲覧・修正・削除する機能を別々に構成するので、アプリケーションを構造化できるようになります。

まずは、既に作成した読書記録の一連のCRUD処理を、Blueprint機能を使って、構造化してみましょう。

今後のことを考えて、フォルダ構成を以下の通りとします。新たにbooksフォルダをwebフォルダとtemplatesフォルダ下に作成し、views.pyとindex.html, register_book.html, each_book.htmlをそれぞれ移動させます。register_book.htmlとeach_book.htmlの名称はそのままでももちろん良いのですが、折角booksフォルダ下におき、booksのregister.html等、関係が明確なので、簡素化のために名称変更しました。

books.py
config.py
web/
    __init__.py
    models.py
    forms.py
    books/            <---  フォルダを作成
      views.py      <--- ここに移動
    templates/
      base.html  
      books/          <--- フォルダを作成  
          index.html       <--- ここに移動  
          register.html   <--- 名前を変更  
          each.html  <--- 名前を変更

さて、view.pyを以下の通り修正します。 Blueprintをインポートし、books = Blueprint('books', __name__)を一行挿入します。これと対になるように、__init__.pyにもfrom Web.books.views import booksおよびapp.register_blueprint(books)の2行を挿入します。

from flask import Blueprint    # 追加

books = Blueprint('books', __name__)     # 追加

@books.route('/')
def index():
    books = Book.query.order_by(Book.date.desc()).all()
    return render_template('books/index.html', books=books)

@books.route('/books/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('books.index'))
    return render_template('books/register.html', form=form)

@books.route('/books/<int:id>/update', methods=['GET','POST'])
def update_book(id):
    book = Book.query.get(id)

    form = BookForm()

    if form.validate_on_submit():
        book.title = form.title.data
        book.author = form.author.data
        book.genre = form.genre.data
        book.date = form.date.data
        db.session.commit()

        return redirect(url_for('books.index'))

    elif request.method == 'GET':
        form.title.data = book.title
        form.author.data = book.author
        form.genre.data = book.genre
        form.date.data = book.date

    return render_template('books/each.html', form=form, id=id)

@books.route('/books/<int:id>/delete', methods=['GET','POST'])
def delete_book(id):
    book = Book.query.get(id)
    db.session.delete(book)
    db.session.commit()
    return redirect(url_for('books.index'))

また、@app.route('/register')@books.route('/books/register')等と修正するほか、url_for('index')url_for('books.index')等と変更します。

# 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.books.views import books  #追加
app.register_blueprint(books)             #追加

著者(Authorクラス)の導入とリレーションの設定

models.pyを以下の通り修正します。Bookクラスでは、author(文字列)ではなく、author_id(整数)のみ保有し、Authorクラスとのリレーションを構築します。Bookクラスには、Foreign Keyとしてauthor.idを以下の通り登録します。

author_id = db.Columns(db.Integer, db.ForeignKey('author.id')) 

また、Authorクラスには、以下の通り、登録します。

books = db.relationship('Book', backref='writer', lazy='dynamic')

backrefがないと、双方向のリレーションがうまく構築できないようです。また、backref='author'にすると、flask-Migrateを利用したデータベース構築がうまくいかないため、backref='writer'にしております。lazyパラメータは、リレーションが構築されたデータを取得する方法を設定するものです。dynamicはクエリを都度行うような場合に設定しておくと良いようです。

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)
    author_id = db.Columns(db.Integer, db.ForeignKey('author.id')) # 追加

    def __repr__(self):
        return '<Book {}>'.format(self.title)

# Authorクラスとして以下を追加
class Author(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(64), index=True,unique=True)
    extras = db.Column(db.String(64))
    books = db.relationship('Book', backref='writer', lazy='dynamic')

    def __repr__(self):
        return '<Author {}>'.format(self.name)

forms.pyに以下を追加します。

class AuthorForm(FlaskForm):
    name = StringField('著者', validators=[DataRequired()])
    extras = StringField('説明')
    submit = SubmitField('登録')

著者(Aurhor)のCRUD操作

さて、AuthorについてもCRUD操作を行えるようにするため、フォルダ構成を以下のようにします。

books.py
config.py
web/
    __init__.py                    <---  修正
    models.py
    forms.py
    books/ 
      views.py
    authors/                      <--- フォルダを作成
               views.py          <---  books/views.pyをコピペ・修正
    templates/
               base.html        <--- 修正
               books/ 
                         index.html
                         register.html
                         each.html 
              authors/                      <--- フォルダを作成
                        index.html        <---  books/index.htmlをコピペ・修正
                        register.html    <---  books/register.htmlをコピペ・修正
                        each.html         <---  books/register.htmlをコピペ・修正

まず、コピペしたauthors/views.pyを修正します。booksauthorsで置き換えていくイメージです。これにもBlueprint機能を適用しますので、authors/views.pyには以下の通り記述。

authors = Blueprint('authors', __name__)

また、init.pyには以下を追加します。

from web.authors.views import authors
app.register_blueprint(authors)

authors/views.pyは以下の通り。

# web/authors/views.py
from web import db
from web.forms import AuthorForm
from web.models import Book, Author
from flask import render_template, redirect, url_for, request
from flask import Blueprint

authors = Blueprint('authors', __name__)

@authors.route('/authors', methods=['GET'])
def index():
    authors = Author.query.order_by(Author.name).all()
    return render_template('/authors/index.html', authors=authors)

@authors.route('/authors/register', methods=['GET','POST'])
def register():
    form = AuthorForm()

    if form.validate_on_submit():
        author = Author(name=form.name.data, extras=form.extras.data)
        db.session.add(author)
        db.session.commit()
        return redirect(url_for('authors.index'))
    return render_template('authors/register.html', form=form)

@authors.route('/authors/<int:id>/update', methods=['GET','POST'])
def update(id):
    author = Author.query.get(id)

    form = AuthorForm()

    if form.validate_on_submit():
        author.name = form.name.data
        author.extras = form.extras.data
        db.session.commit()

        return redirect(url_for('authors.index'))

    elif request.method == 'GET':
        form.name.data = author.name
        form.extras.data = author.extras

    return render_template('authors/each.html', form=form, id=id)

@authors.route('/authors/<int:id>/delete', methods=['GET','POST'])
def delete(id):
    author = Author.query.get(id)
    db.session.delete(author)
    db.session.commit()
    return redirect(url_for('authors.index'))

これに合わせて、`base.html'も修正します。具体的にはメニューバーに「著者一覧」「著者登録」も追加します。

  <nav class="navbar navbar-expand-lg navbar-dark bg-dark">

        <a class="navbar-brand" href="{{ url_for('books.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="{{url_for('books.register')}}">読書の記録</a>
            </li>

            <li class="nav-item active">
              <a class="nav-link" href="{{url_for('authors.index')}}">著者一覧</a>
            </li>
            <li class="nav-item active">
              <a class="nav-link" href="{{url_for('authors.register')}}">著者の登録</a>
            </li>
          </ul>
        </div>
      </nav>

ジャンルをリストから選択できるようにする(wtformsのselectfieldを使う)

wtformsモジュールには、フォームで使うfieldが色々準備されています。これまで使ったStringField、DateFieldのほか、BookeanField、FloatField、IntegerField、PasswordField、RadioField、TextAreaField、TextField等です。その中に、SelectFieldがあり、リストの中から一つ選択する場合に利用します。

具体的には、SelectField('表題', choices=[(value1,label1),(value2,label2),(value3,label3)])と書きます。choicesとして、(value,label)の組合せをリストとして指定します。labelがリストに表示される項目、valueがデータベースに保存される値となります。

分類の仕方は好き好きかと思いますが、図書館の分類は参考になるでしょう。 http://www.libnet.pref.okayama.jp/shiryou/ndc/index.htm
これだと細かすぎるので、ご自身が読まれる本の分類のみピックアップすれば良いのではないかと思います。後は、好みですよね。「小説」と「歴史小説」を分けたい方もいらっしゃるかもしれません。

私は、取り敢えず、以下の通り設定しました。

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

class BookForm(FlaskForm):
    title = StringField('書籍', validators=[DataRequired()])
    author = StringField('著者', validators=[DataRequired()])
    genre = SelectField('ジャンル',choices=[('小説','小説'),('経営','経営'),('歴史','歴史'),('ビジネス','ビジネス'),('宗教哲学','宗教哲学'),('自然科学','自然科学'),('社会科学','社会科学'),('工学','工学'),('芸術','芸術'),('言語','言語'),('趣味','趣味'),('その他','その他')])
    date = DateField('読了日', format="%Y-%m-%d")
    submit = SubmitField('登録')

以下の通り、ジャンルにおいて、デフォルトでは最初のリスト、ここでは「小説」が表示されています。

f:id:akatak:20190714224713p:plain

「小説」をクリックすると、以下の通り、choicesで設定したリストのうちlabelが表示されて、選択できるようになりました。

f:id:akatak:20190714224438p:plain

著者リストを別メニューにて作成し、選択できるようにする(selectfieldを動的に設定する)

さて、最初からある程度分類が決まっているようなものについては、上記のように設定すれば良いでしょう。ところが、著者の場合には、次々登録されていきます。 これに対応するために、著者として登録すると、当該著者がリストに追加され、読書記録データを作成する場合に、そのリストから著者を選べるようにする必要があります。

実はwtformsには、このように動的選択(dynamic choice)ができる仕組みが準備されています。

具体的には、まず、forms.pyのBookFormクラスを以下の通り修正します。

    # author = StringField('著者', validators=[DataRequired()])  # 修正前
    author = SelectField('著者', coerce=int, validators=[DataRequired()])  # 修正後

そして、books/views.pyを以下の通り修正します。

@books.route('/', methods=['GET'])
def index():
    books = Book.query.order_by(Book.date.desc()).all()
    authors = db.session.query(Author).join(Book, Book.author_id == Author.id).all()    #追加
    return render_template('/books/index.html', books=books, authors=authors)

@books.route('/books/register', methods=['GET','POST'])
def register():

    registered_authors = db.session.query(Author).order_by('name')  # 追加
    authors_list = [(i.id, i.name) for i in registered_authors]  # 追加

    form = BookForm()
    form.author.choices = authors_list  # 追加

    if form.validate_on_submit():
        # book = Book(title=form.title.data, author=form.author.data, genre=form.genre.data, date=form.date.data)  # 修正前
        book = Book(title=form.title.data,genre=form.genre.data, date=form.date.data, author_id=form.author.data)  # 修正後
        db.session.add(book)
        db.session.commit()
        return redirect(url_for('books.index'))
    return render_template('books/register.html', form=form)

@books.route('/books/<int:id>/update', methods=['GET','POST'])
def update(id):
    book = Book.query.get(id)

    registered_authors = db.session.query(Author).order_by('name') #追加
    authors_list = [(i.id, i.name) for i in registered_authors]  # 追加

    form = BookForm()
    form.author.choices = authors_list  # 追加

    if form.validate_on_submit():

        author = db.session.query(Author).filter(Author.id == form.author.data).first()   # 追加
        book.title = form.title.data
        book.genre = form.genre.data
        book.author_id = form.author.data  #  追加
        book.author = author.name  # 修正
        book.date = form.date.data
        db.session.commit()

        return redirect(url_for('books.index'))

    elif request.method == 'GET':

        form.title.data = book.title
        form.author.data = book.author_id   # 修正
        form.genre.data = book.genre
        form.date.data = book.date

    return render_template('books/each.html', form=form, id=id)

なお、templates/books/index.htmlも以下の通り修正します。

{% for book in books%}
      <tbody>
        <tr>
          <td> <a href="{{ url_for('books.update', id=book.id) }}"> {{ book.title }} </a></td>
          {% for author in authors %}
            {% if author.id == book.author_id %}
              <td> <a href="{{ url_for('authors.update', id=book.author_id)}}"> {{ author.name }}</a></td>
            {% endif %}
          {% endfor %}
          <td> {{ book.genre }} </td>
          <td> {{ book.date }} </td>
        </tr>
      </tbody>
  {% endfor %}

以下の通り、データベースに登録した著者名をリストから利用できるようになりました。

f:id:akatak:20190715135517p:plain

著者の二重登録エラーを回避する

models.pyにおいて、Authorクラスのname(著者名)はunique=Trueと設定していますので、二重登録をしようとするとエラーが排出されます。同じ本は2回読むこともあろうかと思いますので、models.pyにおいてunique=Trueには設定していません。

著者の二重登録のみ回避する仕組みを導入します。

authors/register.html と authors/update.htmlのif form.validate_on_submit()の箇所に以下を挿入します。

    if form.validate_on_submit():
        check= Author.query.filter(Author.name == form.name.data).first()
        if check:
            errors = '既にこの著者は登録されています。他の著者名を登録してください。'
            return render_template('authors/register.html', form=form, errors=errors)

以下の通り、二重登録しようとするとエラーメッセージが出ました。

f:id:akatak:20190715124526p:plain

当該著者の読書記録がある状況での著者の削除を回避する

現状ですと、読書記録に登録した著者を削除できてしまいます。そのような操作ができないようにviews.pyを修正します。

@authors.route('/authors/<int:id>/delete', methods=['GET','POST'])
def delete(id):
    author = Author.query.get(id)

#  ここから追加します。
    check = Book.query.filter(Book.author_id == id).first()
    if check:
        errors = "この著者による本を先に削除してください。"
        form = AuthorForm()
        form.name.data = author.name
        form.extras.data = author.extras
        return render_template('authors/each.html', form=form, id=id, errors=errors)
#   追加はここまで。
    db.session.delete(author)
    db.session.commit()
    return redirect(url_for('authors.index'))

これもきちんとエラーメッセージが出るようになりました。

f:id:akatak:20190715124701p:plain

削除の際にポップアップ画面を追加する

BootstrapにMordalというコンポーネントがあり、このポップアップに該当します。books/each.htmlとauthors/each.htmlの「削除」ボタンを押したらポップアップが表示されるように、以下の通り、修正します。Bootstrapのページからコピペして修正することで簡単に対応できます。

    <div class="form-group">
        {{form.submit(class="btn btn-primary")}}
        <button type="button" class="btn btn-danger" data-toggle="modal" data-target="#delete_modal">
          削除
        </button>

        <!-- Modal -->
        <div class="modal fade" id="delete_modal" tabindex="-1" role="dialog" aria-labelledby="ModalLabel" aria-hidden="true">
          <div class="modal-dialog" role="document">
            <div class="modal-content">
              <div class="modal-header">
                <h5 class="modal-title" id="ModalLabel">削除の再確認</h5>
                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                  <span aria-hidden="true">&times;</span>
                </button>
              </div>
              <div class="modal-body">
                本当に削除してもいいですか?
              </div>
              <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-dismiss="modal">閉じる</button>
                <a class="btn btn-danger" href="{{ url_for('authors.delete', id=id)}}">削除する</a>
              </div>
            </div>
          </div>
        </div>

    </div>

f:id:akatak:20190715124819p:plain

無事ポップアップ画面が出ました。「閉じる」を押すと、画面がクローズ。「削除する」を押すと、きちんとデータが削除できました。

今回は、かなりいろいろな機能を説明しました。参考までに、以下にスクリプトをアップしておきました。

flask-reading-records/flask-reading-records-v3 at master · tak-akashi/flask-reading-records · GitHub

次回は、ページネーション等を説明したいと思います。

本日はこれにて失礼します。

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

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

前回に続いて、読書記録Webアプリを作って行きましょう。

前回は読書記録データの作成、読み出しを行いましたので、今回は読書記録データの更新、削除とCRUD操作を網羅したいと思います。CRUDとは、Create(データの新規作成)、Read(データの読み込み)、Update(データの更新)、Delete(データの削除)のことです。これらを網羅すれば一通りの機能を作成できます。

Create(読書記録データの作成機能)

前回作成したものですが、念のため、再度記載します。

# 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('/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)

インポートしたBookFormクラスをインスタンス化します。validate_on_submitメソッドにより、POSTリクエスト(register_book.htmlへの入力)があり、かつ、その内容が有効かどうかをチェックします。

そうであれば、入力されたtitle, author, genre, dateをもとにBookオブジェクトを生成し、それをdb.session.add(book)により、データベースにデータとして追加します。さらに、db.session.commit()により、データベースへの登録を完了します。

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

register_book.htmlは以下の通りです。

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

Read(読書記録データの一覧機能)

データ作成機能により作成した読書記録を一覧表示させる機能です。 これも前回記載しましたが、そのまま再掲します。

# 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)

しかし、books = Book.query.all()だと、登録順に表示されてしまいます。一番最近に読んだ本が最上位に来るように修正しましょう。以下のようにorder_by(Book.date.desc())を入れればOKです。desc()はdescening、即ち「降順」を表しています。「昇順」にしたい場合にはasc()にします。

books = Book.query.order_by(Book.date.desc()).all()
@app.route('/')
def index():
    books = Book.query.order_by(Book.date.desc()).all()
    return render_template('index.html', books=books)

render_templatetemplatesフォルダ下にあるindex.htmlを表示します。その際にbooksを一緒にindex.htmlに渡します。booksは、Bookオブジェクトのリストとなっています。

index.htmlは以下の通りです。booksリストから一つ一つBookオブジェクトを取り出して、そのtitleやauthorといったプロパティを表示させます。

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

Update(読書記録データの修正機能)

続いて、データを修正できるようにしましょう。 indexページの読書記録一覧において、書籍をクリックすると、読書記録の登録ページに飛ぶようにします。そこではクリックした書籍データが表示されていて、そのデータを修正して登録すると、修正できるようにします。

index.htmlの書籍名が表示されている箇所<td> {{ book.title }} </td>{{ book.title }}<a href="#"> </a>で挟みます。また、#の箇所には{{url_for('update_book',id=book.id)}}を入力します。

<td> <a href="{{url_for('update_book',id=book.id)}}">{{ book.title }} </a></td>

さて、view.pyを修正しましょう。

まず、requestを追加でインポートします。また、view.pyの最後に、以下の@app.route('/update/<int:id>')以下を追加します。

# web/views.py

from flask import render_template, redirect, url_for, request

@app.route('/<int:id>/update', methods=['GET','POST'])
def update_book(id):
    book = Book.query.get(id)

    form = BookForm()

    if form.validate_on_submit():
        book.title = form.title.data
        book.author = form.author.data
        book.genre = form.genre.data
        book.date = form.date.data
        db.session.commit()

        return redirect(url_for('index'))

    elif request.method == 'GET':
        form.title.data = book.title
        form.author.data = book.author
        form.genre.data = book.genre
        form.date.data = book.date

    return render_template('each_book.html', form=form, id=id)

register_book.htmlをコピーして、`each_book.html'を作っておきます。現時点では中身は全く同じで結構です(後ほど、削除機能の作成の際に修正します)。

ここで、python books.pyもしくは環境変数を設定しておいて、flask runにて実行してみます。

f:id:akatak:20190713165115p:plain

書籍名が青くなっており、リンクが貼られました。前回とは読書記録データが多少ことなっておりますが、ご容赦ください。

一つ目の書籍名をクリックしてみます。

f:id:akatak:20190713165344p:plain

読書記録データが表示されました。書籍名に「よん」を加えて、登録ボタンを押してみます。

f:id:akatak:20190713165518p:plain

f:id:akatak:20190713165554p:plain

きちんと修正ができました。ここで、具体的に見ていきましょう。

ます、indexページにて、書籍名をクリックすると、update_book関数が呼び出されます。その際に idとして読書記録データのbook.idも渡されます。

なお、@app.route内は、/<id>/updateでも問題ありません。通常、必ず整数を受け取るように制限したい場合が多く、その場合、<int:id>とします。そうすると、整数以外の値が渡された際にはエラーになります。

さて、book = Book.query.get(id)により、データベースから、当該idに紐ついているデータが検索され、bookに渡されます。一方で、登録の際と同様に、BookFormをインスタンス化しておきます。

書籍名をクリックした際は、情報を取得するだけですのでrequest.methodはGETになります。従って、elif以下となり、bookが保持しているデータを項目毎にformに渡しています。その上で、register_book.htmlを表示していますので、登録する際と同じ画面を利用していますが、データが表示されています。

そこで、データの一部を変更して、「登録」ボタン(Submit)を押下すると、今度は同じ関数のif以下が適用され、formに保存されている修正後のデータが、該当するbookのプロパティに保存され、commitすることで修正が確定します。

Delete(読書記録データの削除機能)

最後に、データを削除する機能です。 これは、削除したいデータをBook.query.get(id)にて呼び出して、当該データをdb.session.deleteにて削除し、最終db.session.commit()で確定させます。そして、indexページに戻ります。以下がスクリプトです。

@books.route('/book/<int:id>/delete', methods=['GET','POST'])
def delete_book(id):
    book = Book.query.get(id)
    db.session.delete(book)
    db.session.commit()
    return redirect(url_for('index'))

そして、each_book.htmlを修正します。以下の通り、<a>...</a>タグを追加します。

      <div class="form-group">
        {{form.submit(class="btn btn-primary")}}
        <a class="btn btn-danger" href="{{ url_for('delete_book', id=id)}}">削除</a>

      </div>

以上です。それでは実行してみましょう。indexページにて「吾輩は猫である」をクリックします。

f:id:akatak:20190713175856p:plain

削除ボタンが追加されています。削除ボタンを押してみます。

f:id:akatak:20190713180026p:plain

無事削除ができました。 これでCRUD機能が実装できました。 今回のスクリプトを以下にv2としてアップロードしておきましたので、参考にしてみてください。

github.com

アプリの機能としてはまだ不十分かと思いますので、次回以降バージョンアップを図っていきたいと思います。

例えば、著者を別画面で登録できるようにして、読書記録登録時には、そこから選択できるようにしたり、ペジネーション(読書記録が多くなってきた場合に1ページあたりの表示件数を制限)できるようにしたりしていきたいと思います。

今回はこれにて。

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

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

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

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

前回、読書記録Webアプリで利用するモジュールの概略をご説明しましたので、今回から具体的にWebアプリの作成に入っていきましょう。

まずは、Flaskホームページのクイックスタートに掲載されている最小規模のアプリケーション例からご紹介します。

FlaskでHello World!を表示する

予めpip install flask等でFlaskをインストールしておきます。そして、ファイル名をhello.pyとでもして、以下のスクリプトを書きます。

# hello.py

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return "Hello World!"

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

これだけです。ターミナルからpython hello.pyと実行します。

$ python hello.py
 * Running on http://127.0.0.1:5000/

すると上記のように表示されますので、ブラウザのアドレス入力欄にhttp://127.0.0.1:5000/を入力します。”Hello World!”と出力されました。おめでとうございます。簡単に作成できました。この気軽さがFlaskの良いところですね。

上記のスクリプトでやっていることは以下の通りです。 まず、 flaskからFlask クラスをインポートし、次にFlaskインスタンスを生成します。 そして、route() デコレータを使用し、関数を起動するURLをFlaskインスタンスに教えます。今回は、routeの中が/なので、実行後にhttp://127.0.0.1:5000/を入力すると、デコレータの次に記載の関数が呼び出され、Hello World!という文字列が返されます。

最後に runメソッドを使い、ローカルサーバー上でアプリケーションを実行します。if __name__ == '__main__':は、そのスクリプトがPyrhonから直接実行されたときだけ、 そのサーバ上で動くことになります。モジュールとして他のプログラムからインポートされたときにはapp.run()は実行されません。

読書記録Webアプリの構成を考える

簡単にFlaskアプリが作成できることを確認できましたところで、読書記録Webアプリの作成に取り掛かっていきます。 まず、最初に全体の構成を見てみましょう。

books.py
config.py
web/
    __init__.py
    forms.py
    models.py
    app.db
    books/
        views.py
    templates/
        base.html
        books/
            index.html
            each_book.html
            register_book.html

(以下は、いずれ追加していきます)
    authors/
    publishers/
    searches/
ファイル/ディレクトリ名 説明
books.py 本アプリの実行用のファイル。中身は非常にシンプル
config.py 各種設定を格納しておくファイル。init.pyで各種設定を記述しても良いが、config.pyに設定を外出ししておくと、init.pyファイルがすっきりする。
web/init.py 本アプリを立ち上げた際に、最初に実行され、様々なコンポーネントを読み込む
web/forms.py 書籍名など、ユーザー入力用のフォームを定めるもの
web/models.py データベースのテーブル構成を定めるもの。テーブルに相当するクラスを定義しておきます。ここできちんと定義しておくと、sqlalchemy等により、簡単にsqlite3等にテーブルが生成されます。
web/app.db sqlite3データベースです。どのフォルダ下に作るのかについては、上記のconfig.pyで指定しておきます。
web/books/views.py 本アプリのURLとそのURLを指定した場合に実行するスクリプトを記述するファイルです。routeデコレータでurlを指定し、その直後に関数を記述するようにします。webフォルダ直下にviews.pyをおいても良いのですが、今回は、書籍だけでなく、著者や発行者も入力できるようにするつもりですので、書籍を入力する場合のviews.pyだと分かるようにbooksフォルダ傘下にviews.pyを置いています
web/templates/ このtemplatesの傘下には、htmlファイルを基本的に配置します。booksというフォルダを作成したのは、この後、書籍だけでなく、著者は発行者についても登録、削除等を行うようにしたいので、同じような名称のhtmlファイルができるので、予めフォルダで分けておきたいと思っているからです。

さて、具体的に次回からコーディングの例をご紹介していきますね。

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

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

先日、日経ソフトウェア(2019年3月号)を見ていたら『Flask+SQLite3でWeb-DBアプリ開発!蔵書管理データベースで基本をマスターしよう』という記事が掲載されていました。それはそれで作ってみたのですが、sqlite3に依存するコードとなっていたので、将来的にHeroku等のクラウドサービスへデプロイし、PostgresSQLの利用を考える場合には、コードを書き換えなくてはいけなくなります。

そこで、上記の記事を参考にしつつ、データーベースの種類が変わってもコードの変更が最小限になるように、SQLAlchemy等を利用した簡単な読書記録Webアプリを新たに作りましたので、次回以降ご紹介したいと思います。今回は、そのWebアプリで利用したパッケージを先にご紹介します。

Flaskとは

Flaskは、PythonによるWebアプリケーション開発用のフレームワークです。開発者がマイクロフレームワークと呼んでいるように軽量で最小限の機能しかない一方で、柔軟性が高いという特徴を持っています。Pythonにおける有名なフレームワークであるDjangoはWebアプリケーションの作成までの決まりごとが多く、ハードルが高いため、取り敢えずでいいからWebアプリケーションを簡単に作ってみたいという方には Flaskがオススメです。

Flask公式ホームページ

SQLAlchemyとは

SQLAlchemyはPythonのORM(OBject Relational Mapper)です。Python用ORMの中では人気があり、広く利用されているようです。

ORM(Object-relational mapping、オブジェクト関係マッピング)とは、データベースとオブジェクト指向プログラミングの間の非互換なデータを変換するプログラミング技法のことをいいます(出典:Wikipedia)。Object-relational Mapperは、そうしたプログラム技法を利用したパッケージのことを指しています。

これにより、SQLiteMySQLPostgreSQLといったデータベースの各々異なるSQLを直接記述することなく、Pythonオブジェクトを通じて、データベースを扱えることができます。加えて、データベースの種類に関係なく、共通のコードを利用してデータベースを取り扱うことができるので、コードを変更せずに、自分のパソコンではSQLiteを利用して、Herokuにデプロイした後はPostgrteSQLを利用するといったことが可能となります。

SQLAlchemy ORM Tutorial

Flask-SQLAlchemyとは

Flask-SQLAlchemyは、FlaskにおいてSQLAlchemyをより簡単に利用するための拡張パッケージです。これにより、Flaskからより簡単にSQLAlchemyを利用できるようになります。

例えば、Flask-SQLAlchemyでは、dbオブジェクトを通じて、テーブルの作成や検索を実行できるようになります。SQLAlchemyのみの場合とFlask-SQLAlchemyを利用した場合の違いは、例えば以下の通り。

【導入(前置き)部分】
▪️SQLAlchemyのみの場合

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String

engine = create_engine('sqlite:////tmp/test.db')
Base = declarative_base()

class User(Base):
    id = Column(Integer, primary_key=True)
    Username = Column(String)
    email = Column(String)
    def __repr__(self):
        return '<User %r>' % self.username

▪️Flask-SQLAlchemyを利用した場合

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db'
db = SQLAlchemy(app)

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    Username = db.Column(db.String)
    email = db.Column(db.String)
    def __repr__(self):
        return '<User %r>' % self.username

【テーブルの作成】
▪️SQLAlchemyのみの場合

Base.metadata.create_all(engine)

▪️Flask-SQLAlchemyを利用した場合

db.create_all()

【データの追加・コミット(その準備)】
▪️SQLAlchemyのみの場合

from sqlalchemy.orm import sessionmaker
from yourapplication import User

Session = sessionmaker(bind=engine)
session = Session()

user1 = User(username='user1', email='user1@example.com')

session.add(user1)
session.commit()

▪️Flask-SQLAlchemyを利用した場合

from yourapplication import db, User

user1 = User(username='user1', email='user1@example.com')

db.session.add(user1)
db.session.commit()

【テーブルを検索】
▪️SQLAlchemyのみの場合

session.query(User).order_by(User.id)

▪️Flask-SQLAlchemyを利用した場合

db.session.query(User).order_by(User.id)

これ以上は省略しますが、flask-sqlalchemyを利用した方が、dbオブジェクトを通じて、テーブルを作成したり、検索したりできますので、便利かと思います。

flask-sqlalchemy Documentation

Flask-Migrateとは

Flask-Migrateは、Flaskアプリケション用のデータベース移行ツールで、Alembicというパッケージを利用しています。このFlask-Migrateにより、コマンドラインによるデータベース操作が可能となります。

例えば、'flask db init'とコマンドラインに入力すると、models.py等のPythonモジュールに記載された各クラスからデータベースのテーブルを作成してくれますので、Pythonスクリプトを立ち上げて、db.create_all()等をいちいち実行しなくて済みます。また、データベースの構造を変えていく際に、履歴を保存するほか、データの移行を実行したり、戻したりすることが可能となります。

flask-Migrate Documentation

なお、AlembicはSQLAlchemyの作者が作成したデータベース移行ツールで、以下の機能を提供しています(alembic 1.0.10)。
- テーブルその他の構造を変更するためにデータベースに対してALTERステートメントを発行
- 移行のためのスクリプトを記述する仕組みを提供。各スクリプトは、対象となるデータベースを新しいバージョンにアップグレードできる特定の一連の手順と、オプションで、同じ手順を逆の順序でダウングレードできる一連の手順を示す
- スクリプトを順次実行することを許可

flask-WTFとは

flask-WTFはWTFormsというパッケージをFlaskで利用可能とするパッケージです。WTFormsは、flaskその他のWebフレームワークで利用可能なパッケージの一つで、Pythonを利用して各種フォームの作成や入力時のチェックを柔軟に行うことができます。

flask-WTF Documentation

今回は以上です。次回以降、具体的にコーディング事例をご紹介します。

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

令和元年を迎えて

令和元年となりました。新たな素晴らしい時代の幕開けとなりますよう祈念します。皆様はいかがお過ごしでしょうか。私自身は、具体的な目標を持って、少しづつでも良いので前に進んでいけたらいいなぁと 思っています。

一方で、最近、ブログの更新頻度がとみに減ってきてしまいました。言い訳としてはアウトプットの前提としてのインプットに注力している、ということになりますでしょうか。インプットとアウトプットのバランスが難しいと感じています。日々更新されている方は本当にすごいと感心します。

具体的に現在インプットに注力している分野は、①Flask ②統計 ③機械学習です。それぞれ簡単に説明しましょう。

Flask

PythonにおけるWebフレームワークの一つで、Djangoと比較すると軽量で機能が限定的である一方で、学習コストが低いという特徴があります。一時期、Djangoを勉強したこともあるのですが、簡単なアプリケーションを作るまでに時間がかかり、半年くらいブランクを空けたら、すっかり忘れてしまいました(笑)。今回、Flaskに宗旨替えし、簡単なWebアプリケーションを作りましたので、別の機会にご紹介します。

主に学習に利用したのが、①Udemy(オンライン学習サイト)、②書籍、③ウェブページです。Flaskだけでなく、Pythonの学習にはこの組合せが多いです。

Udemyのオンライン講座は定価は1〜3万円するのですが、定期的に割引を行う期間があって、その際には、1200〜1800円で購入できるのでお得です。日本語の講座に比べて英語講座は内容が充実しています(20〜30時間の講座でも、場合によっては1200円で買える!)。最近は講座によっては日本語の字幕も付いている場合があるので、ハードルは多少低くなっていると思います。私は、Jose Portilla氏の講座が分かりやすく、エクササイズも適度にあり、気に入っているため、いくつかの講座でお世話になっています。以下のFlaskの講座もそうです(残念ながら日本語の字幕はまだないようです)。

https://www.udemy.com/course/python-and-flask-bootcamp-create-websites-using-flask/

書籍は、主に以下の2冊を利用しました。前者は、簡単なBlogアプリを作成しながら、Flaskの全体像を把握するのに分かりやすいです。後者は、英語ですが、Mega-Tutorialと題するだけあって、flask-sqlalchemyやflask-migrateなどの機能も具体的に説明があって良いかと思います。

ホームページは、以下のFlaskチュートリアルなどを利用して学習してきました。

a2c.bitbucket.io

統計

統計を勉強しようと思った理由は主に2つあります。1つは不動産の分析において、重回帰分析を使いましたが、その解釈を含めて、掘り下げる必要があると感じたこと。また、もう1つは、Pythonによる機械学習の勉強をしていくと、どうしても統計を理解していないと単なる作業になってしまい、差別化につながらないと思ったことです。

そこで思い立って、統計検定2級を受験することにしました!

www.toukei-kentei.jp

学習に利用しているのは、主に書籍です。以下の3冊にて学習を進めています。

入門の名に相応しく、網羅的に概念的な理解をするのに適した書籍です。数式は最小限に抑えられており、数式を使って理解したい方には向きません。統計の初学者にとっては、記述統計(平均・分散・標準偏差)はもちろん、確率分布(正規分布、標準正規分布、t分布、χ2分布、F分布)から検定・多変量解析・実験計画法まで、更にはノンパラメトリック手法・因子分析・主成分分析も一応網羅しており、一通りの概念を理解するのは良い本かと思います。

基本統計学 第4版

基本統計学 第4版

上記と同じような範囲を、数理的な側面からしっかり説明しているほか、具体例も豊富。また、標本が少ない場合の検定等でなぜ自由度n-1を使うのかを厳密に説明しています。上記入門とセットでみると完璧かなと思います。これで受からなかったら...自分の理解力の問題。

日本統計学会公式認定 統計検定 2級 公式問題集[2016〜2018年]

日本統計学会公式認定 統計検定 2級 公式問題集[2016〜2018年]

これらについても、記事にまとめていけたらと思っています。

機会学習

これについても ①Udemyと②書籍を中心に学習しています。

https://www.udemy.com/optworks_1/www.udemy.com

機械学習の一部ですが、ビジネスの実例を分析していきますので、導入としては良いかと思います。

https://www.udemy.com/python-for-data-science-and-machine-learning-bootcamp/www.udemy.com Jose Portilla氏の講座です。機械学習を網羅的に学習するのには良いと思います。今、学習中です。日本語字幕も使えます。

その他、Udemyでは、日本語で機械学習の講座も増えてきていますので、それらを利用するのも手かと思います。もちろん割引が適用されるタイミングで、出来れば1200円の時を狙いたいものです。

機械学習における日本語の書籍も最近いろいろと出ていますね。理論面からの勉強には、以下が無料で良いとJose Portilla氏の講座で勧められ、ちょっとずつ読み進めていますが、何せ英語。時間がかかっています。日本語訳もあるようですが、高すぎ。地道にやっていこう。まずは上記の統計の勉強を優先。その後、読み進めていきます。これも纏め記事が書けたら良いなとは思っています。

www-bcf.usc.edu

以下が上記の日本語版。最近出たようですが、値段が高め。

Rによる 統計的学習入門

Rによる 統計的学習入門

  • 作者: Gareth James,Daniela Witten,Trevor Hastie,Robert Tibshirani,落海浩,首藤信通
  • 出版社/メーカー: 朝倉書店
  • 発売日: 2018/08/03
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログ (1件) を見る

最後に

以上の学習が一段落したら、不動産価格分析の精緻化や資産運用への応用などまだまだやりたいことがあります。新たな時代になったことですし、また、新たな気持ちで前向きに取り組んでいければと思います。