akatak blog

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

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

これまで機能のアップデートを図ってきた読書記録Webアプリですが、最終化を図りたいと思います。

今回の主要テーマは検索機能の実装ですが、まずは、それ以外のところを改良していきます。

入力項目の追加(オススメ度・コメント)

折角データベースを作成するので、入力項目を充実させましょう。読書をして、その本が良かったのかどうか、「オススメ度」を5段階で入力できるようにします。また、本の感想などを入力できるように「コメント」欄も作成しましょう。

まずは、models.pyとforms.pyの修正です。models.pyにおいては、Bookクラスにrecommendedとcommentを追加します。

# web/models.py
class Book(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(64), index=True)
    genre = db.Column(db.String(64), index=True)
    date = db.Column(db.Date)
    recommended = db.Column(db.Integer)      #追加
    comment = db.Column(db.String(256))      #追加
    author_id = db.Column(db.Integer, db.ForeignKey('author.id'))

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

同様に、forms.pyにおいては、recommendedはSelectFieldにて(データベースに保存する値,'表示')の組み合わせをchoicesで指定します。また、commentはTextAreaFieldにて指定しました。

# web/forms.py
class BookForm(FlaskForm):
    title = StringField('書籍', validators=[DataRequired()])
    author = SelectField('著者', coerce=int, validators=[DataRequired()])
    genre = SelectField('ジャンル',choices=[('小説','小説'),('経営','経営'),('歴史','歴史'),('ビジネス','ビジネス'),('宗教哲学','宗教哲学'),('自然科学','自然科学'),('社会科学','社会科学'),('工学','工学'),('芸術','芸術'),('言語','言語'),('趣味','趣味'),('その他','その他')])
    date = DateField('読了日', format="%Y-%m-%d")
    recommended = SelectField('オススメ度', choices=[('5','5: とてもオススメ'),('4','4: ややオススメ'),('3','3: 普通'),('2','2: 余りオススメしない'),('1','1: 全くオススメしない')]) #追加
    comment = TextAreaField('コメント')  #追加
    submit = SubmitField('登録')

これらの変更と併せて、register.html、index.html、each.htmlを修正します。 register.htmlとeach.htmlには、以下の入力を追加します。

  <div class="form-group">
    {{ form.recommended.label(class="form-control-label") }}
    {{ form.recommended(class="form-control form-control-lg") }}
  </div>
  <div class="form-group">
    {{ form.comment.label(class="form-control-label") }}
    {{ form.comment(class="form-control form-control-lg") }}
  </div>

また、index.htmlは、以下の通り、修正します。コメントは長くなる場合がありますので、index.htmlの一覧には表示しないことにしました。

<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.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>
          <td> {{ book.recommended }} </td>    追加

        </tr>
      </tbody>
  {% endfor %}
</table>

読了日の入力の簡素化

そうそう「読了日」も改良しましょう。フォーマットが”yyyy-mm-dd”等と予め指定する仕様になっているので、カレンダーから選択できるようにすれば、入力の際にフォーマットも気にせず楽ですよね。wtformsにはそのようなフォーマットが準備されています。具体的には、wtforms.fields.html5からDateFieldをインポートするように変更します。BookFormクラスは変更の必要がありません。

# 修正前
from wtforms import StringField, DateField, SubmitField, SelectField, TextAreaField

# 修正後
from wtforms import StringField, SubmitField, SelectField, TextAreaField
from wtforms.fields.html5 import DateField

検索機能の実装

それでは、今回の主要テーマである検索機能の実装をしていきます。 検索は、ひとつの大きな機能ですので、フォルダ構成を以下の通り変更します。また、Blueprint機能を利用します。

books.py
config.py
web/
    __init__.py                <---  修正
    models.py
    forms.py                   <--- 修正
    books/ 
       views.py
    authors/ 
         views.py
    searches/                  <--- フォルダを新規作成
         views.py              <--- 新規作成
    templates/
               base.html        <--- 修正
               books/ 
                      index.html
                      register.html
                      each.html 
               authors/
                      index.html
                      register.html
                      each.html
               searches/
                      search.html
                      search_result.html

まずは、forms.pyにSearchFormクラスを追加します。

# web/forms.py
class SearchForm(FlaskForm):
    title = StringField('書籍')
    author = SelectField('著者', coerce=int)
    start_date = DateField('検索開始日', format="%Y-%m-%d")
    end_date = DateField('検索終了日', format="%Y-%m-%d")
    submit = SubmitField('検索')

取り敢えず、views.pyを以下の通り作成します(箱を準備します)。

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

searches = Blueprint('searches', __name__)

@searches.route('/searches/', methods=['GET','POST'])
def index_search():
    return render_template('searches/search.html', form=form)

そうそう、__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.books.views import books
from web.authors.views import authors
from web.searches.views import searches    # これと
app.register_blueprint(books)
app.register_blueprint(authors)
app.register_blueprint(searches)           # これを追加

ここまでは枠組みです。それでは検索機能を作っていきます。 まずは、検索ページですね。以下の通り作成します。register.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.start_date.label(class="form-control-label") }}
        {{ form.start_date(class="form-control form-control-lg") }}
      </div>
      <div class="form-group">
        {{ form.end_date.label(class="form-control-label") }}
        {{ form.end_date(class="form-control form-control-lg") }}
      </div>
      {% if errors %}
        <p>{{ errors }}</p>
      {% endif %}
      <div class="form-group">
        {{form.submit(class="btn btn-primary")}}
      </div>

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

メニュー画面に「検索」を追加しておきましょう。

# web/templates/base.html

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

            <li class="nav-item active">
              <a class="nav-link" href="{{url_for('searches.index_search')}}">検索</a>
            </li>

          </ul>
        </div>

検索結果表示画面(search_results.html)は、基本的にはindex.htmlと一緒です。 ここまでできたところで、views.pyを作り込んでいきましょう。まず、全体像です。

# web/searches/views.py

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

    registered_authors = db.session.query(Author).order_by('name')
    authors_list = [(0,"")]
    for i in registered_authors:
        authors_list.append([i.id, i.name])

    form = SearchForm()
    form.author.choices = authors_list

    if form.start_date.data is None:
        form.start_date.data = datetime.date(datetime.datetime.today().year,1,1)
    if form.end_date.data is None:
        form.end_date.data = datetime.datetime.today()

    if form.validate_on_submit():

        if form.author.data != 0:
            books = Book.query.filter(Book.title.like('%' + form.title.data + '%')).filter(Book.author_id==form.author.data).filter(Book.date>=form.start_date.data).filter(Book.date<=form.end_date.data)
        else:
            books = Book.query.filter(Book.title.like('%' + form.title.data + '%')).filter(Book.date>=form.start_date.data).filter(Book.date<=form.end_date.data)

        books = books.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()

        session['title'] = form.title.data
        session['author'] = form.author.data
        session['start_date'] = form.start_date.data.strftime('%Y-%m-%d')
        session['end_date'] = form.end_date.data.strftime('%Y-%m-%d')

        return render_template('searches/search_results.html', books=books, authors=authors)
    return render_template('searches/search.html', form=form)

上から具体的に説明していきます。

registered_authors = db.session.query(Author).order_by('name')
    authors_list = [(0,"")]
    for i in registered_authors:
        authors_list.append([i.id, i.name])

検索において、著者を選ぶリストを作成する部分です。読書記録を登録する画面では authors_list = [(i.id, i.name) for i in registered_authors]の一行でしたが、検索においては author_list =以下3行になっています。検索においては、リストから誰も選ばないという選択肢を作るためです。デフォルトでの表示は(0,"")となります。

if form.start_date.data is None:
    form.start_date.data = datetime.date(datetime.datetime.today().year,1,1)
if form.end_date.data is None:
    form.end_date.data = datetime.datetime.today()

これは検索画面を開いた際に、検索開始日と検索終了日のデフォルト値を作成するものです。これがないと、検索時に必ず、検索開始日と検索終了日を入力しないといけなくなるため手間です。それを避けるためのものです。デフォルト値は、検索開始日が当年の1月1日、検索終了日が当日としています。

 if form.author.data != 0:
        books = Book.query.filter(Book.title.like('%' + form.title.data + '%')).filter(Book.author_id==form.author.data).filter(Book.date>=form.start_date.data).filter(Book.date<=form.end_date.data)
 else:
        books = Book.query.filter(Book.title.like('%' + form.title.data + '%')).filter(Book.date>=form.start_date.data).filter(Book.date<=form.end_date.data)

これは、検索画面において、著者が選択されていない、すなわち、form.author.dataが0の場合と、著者が選択されている場合の処理を分けるようにしています。book.author_id = 0は存在しないため、同じ処理を行うと検索結果が現れなくなってしまうためです。

 session['title'] = form.title.data
 session['author'] = form.author.data
 session['start_date'] = form.start_date.data.strftime('%Y-%m-%d')
 session['end_date'] = form.end_date.data.strftime('%Y-%m-%d')

検索結果もページネーション機能を利用します。異なるページに移る際にも、検索条件を保持できるようにFlaskのSessionを利用しています。これにより検索条件はSessionを通じて、サーバー上に暗号化されて保存されます(config.pyにてSECRET_KEYの設定が必要)。なお、wtformsのDateFieldはdatetime形式で日付が保存されるため、Sessionに保存する際には、一旦、テキスト形式に変換しています。

検索結果の2ページ以降を表示する箇所を作成します。 先ほど、Sessionを通じて保存した検索条件を取り出して、formに格納します。その際に日付についてはテキスト形式からdatetime形式に変換しておきます。

それ以降の検索については、index_search()と同様です。

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

    form = SearchForm()

    form.title.data = session.get('title')
    form.author.data = session.get('author')
    form.start_date.data = datetime.datetime.strptime(session.get('start_date'),'%Y-%m-%d')
    form.end_date.data = datetime.datetime.strptime(session.get('end_date'),'%Y-%m-%d')

    if form.author.data != 0:
        books = Book.query.filter(Book.title.like('%' + form.title.data + '%')).filter(Book.author_id==form.author.data).filter(Book.date>=form.start_date.data).filter(Book.date<=form.end_date.data)
    else:
        books = Book.query.filter(Book.title.like('%' + form.title.data + '%')).filter(Book.date>=form.start_date.data).filter(Book.date<=form.end_date.data)
    books = books.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('searches/search_results.html', books=books, authors=authors)

以上です。画面を見てみましょう。 まずは、検索画面。

f:id:akatak:20190720160511p:plain

著者を選択せずに、検索。

f:id:akatak:20190720162859p:plain

どうやらワークしていますね。テストのため1ページ当たりの件数を1件に設定していますので、ちょっと変ですが。 これで一旦、読書記録Webアプリは完成しました。

読書記録Webアプリで個人で利用することを想定していますので、ログイン機能は実装していません。 これについては、今回のシリーズとは別の機会にご紹介できればと思います。

最近はDjangoに注力しており、今のところご紹介できる見込みはありません。すみません。【2020/9/1追記】

今回もスクリプトを以下にアップロードしております。ご参考まで。

github.com

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp

akatak.hatenadiary.jp