akatak’s blog

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

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