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を修正します。books
をauthors
で置き換えていくイメージです。これにも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('登録')
以下の通り、ジャンルにおいて、デフォルトでは最初のリスト、ここでは「小説」が表示されています。
「小説」をクリックすると、以下の通り、choicesで設定したリストのうちlabelが表示されて、選択できるようになりました。
著者リストを別メニューにて作成し、選択できるようにする(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 %}
以下の通り、データベースに登録した著者名をリストから利用できるようになりました。
著者の二重登録エラーを回避する
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)
以下の通り、二重登録しようとするとエラーメッセージが出ました。
当該著者の読書記録がある状況での著者の削除を回避する
現状ですと、読書記録に登録した著者を削除できてしまいます。そのような操作ができないように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'))
これもきちんとエラーメッセージが出るようになりました。
削除の際にポップアップ画面を追加する
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">×</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>
無事ポップアップ画面が出ました。「閉じる」を押すと、画面がクローズ。「削除する」を押すと、きちんとデータが削除できました。
今回は、かなりいろいろな機能を説明しました。参考までに、以下にスクリプトをアップしておきました。
flask-reading-records/flask-reading-records-v3 at master · tak-akashi/flask-reading-records · GitHub
次回は、ページネーション等を説明したいと思います。
本日はこれにて失礼します。