akatak blog

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

PlotlyとCufflinksでローソク足チャートを描く

Plotlyとはデータ可視化のためのプラットフォームです。

plot.ly

Pythonだけでなく、RやJavaScriptなどにも対応しています。オンラインモードだと無料アカウントを作る必要がありますが、外部公開しない設定にする場合には課金されます。ただし、オフラインで使う場合、特にアカウント作る必要はありません。

Plotlyを使うと、このような(↓)いろいろなチャートを作成できます。

plot.ly

基本的にJupyter Notebookで利用できますし、グラフにカーソルを合わせると、グラフを構成している数値が表示されます。また、拡大・縮小などもできるので便利です。

さらに、Cufflinksというパッケージを合わせて利用すると、Pandasのデータフレームを使ってPlotlyでチャートを描けるようになります。

先日、以下の本を見ていたら、ローソク足チャートが紹介されていましたので、試してみました。

Python for Finance: Mastering Data-Driven Finance

Python for Finance: Mastering Data-Driven Finance

インストール

以下のサイトを見ると、condaを利用してのインストールは利用できないようですので、pipを利用してインストールします。

https://anaconda.org/gwinnen/plotly-and-cufflinks/notebook

pip install plotly
pip install cufflinks

 株価を取得する

株価のヒストリカルデータを無料で取得するのは、その時々でそれまで利用できていた方法ができなくなったりすることもあり、結構大変なんですよね。

日本の株価については、現時点では、以下のサイトで個別企業毎にCSVファイルをダウンロードして利用できます。

kabuoji3.com

米国の株価については、現時点では、Yahoo Financeからのデータがまた取得できているようですので、pandas-datareaderというモジュールを利用します。

pip install pandas-datareader

でインストールします。

import pandas_datareader.data as web

df  = web.DataReader('^N225', 'yahoo', '2000/1/1','2019/10/16')

とすれば、Yahoo Financeから日経225のデータを取得して、データフレーム形式に格納できます。

日経225ローソク足チャートを描く

import plotly.offline as pyo
import cufflinks as cf
import numpy as np

まず、上記をインポートしておきます。 また、Jupyter notebookにて利用できるように、以下を実行します。

pyo.init_notebook_mode()

さて、先ほどの日経225のデータのうち直近60日分切り出し、quotesとします(日々のデータを見やすくし、確認するためです)。

quotes = df.iloc[-60:]
quotes.head()

quotesの最初の5行を表示させると以下の通り。

f:id:akatak:20191020120552p:plain

さて、このquotesをローソク足チャートにしましょう。 非常に簡単です。以下の通り、実行するだけです。

qf = cf.QuantFig(quotes, name='日経 225')

pyo.iplot(
    qf.iplot(asFigure=True)
)

f:id:akatak:20191020120857p:plain

以上です。と言いたいところですが、よく見てみると、横軸に土日も表示されていて、その場合もちろん株価はないので、チャートにギャップができてしまっています。

これを解消するためにいろいろ調べてみました。 英語のサイトでも議論になっていて、根本的な解決策はまだないようです。 したがって、横軸の表示を工夫するしかないのですが、以下のサイトを参考にさせていただきました。

qiita.com

これをQuantFigureオブジェクトにも適用できるよう、修正する必要があります。

まずは、quotesのインデックスをリセットします。

quotes.reset_index(inplace=True)

この段階でチャートを描きます。

qf = cf.QuantFig(quotes, name='日経225')
pyo.iplot(
    qf.iplot(asFigure=True)
)

f:id:akatak:20191020121745p:plain

土日が表示されなくなり、ギャップはなくなりました。しかし、横軸が単に数字の羅列となってしまいました。

そこで以下の通り、工夫します。直接QuantFigureオブジェクトの修正がうまくいきませんでしたので、一旦、Figureオブジェクトにして、そのFigureオブジェクトを修正しています。

figure = qf.iplot(asFigure=True)

figure['layout'].update({
    'xaxis': {'tickmode':'array',
              'tickvals': np.arange(0, quotes.index[-1],5),
              'ticktext': [x.strftime('%y/%m/%d') for x in quotes['Date']][0::5],
             'tickfont':{'size':10}}
})

なお、参考にしたサイトと同様に、5日置きにデータを表示するようにしています。

pyo.iplot(
    figure
)

f:id:akatak:20191020122349p:plain

うまく行きました。

ただし、グラフの左側と下側の目盛の表記が切れてしまっています。これを修正するには、marginを調整します。デフォルトでは左右上下ともmarginが30となっていますので、以下の通り、左と下側を少し大きめにしてみると良いかと思います。

figure['layout'].update({
    'xaxis': {'tickmode':'array',
              'tickvals': np.arange(0, quotes.index[-1],5),
              'ticktext': [x.strftime('%y/%m/%d') for x in quotes['Date']][0::5],
              'tickfont':{'size':10}},
    'margin': {'b': 70, 'l':40, 'r':30, 't':30}     # ここを追加
})

なお、QuantFigureを利用すると何が良いかというと、テクニカル分析用のチャートを簡単に追加するメソッドが準備されていることです。

ボリンジャーバンドを追加する

# ボリンジャーバンドを追加します
qf.add_bollinger_bands()

# あとはこれまでと同様にFigureオブジェクトを修正して、表示するのみ
figure = qf.iplot(asFigure=True)
figure['layout'].update({
    'xaxis': {'tickmode':'array',
              'tickvals': np.arange(0, quotes.index[-1],5),
              'ticktext': [x.strftime('%y/%m/%d') for x in quotes['Date']][0::5],
             'tickfont':{'size':10}}
})
pyo.iplot(
    figure
)

f:id:akatak:20191020122951p:plain

簡単ですね〜。うれしくなってきました。

MACD出来高も表示させる

MACD出来高もついで追加してみましょう。

qf.add_macd()
qf.add_volume()

さて、表示させます。

figure = qf.iplot(asFigure=True)
figure['layout'].update({
    'xaxis': {'tickmode':'array',
              'tickvals': np.arange(0, quotes.index[-1],5),
              'ticktext': [x.strftime('%y/%m/%d') for x in quotes['Date']][0::5],
             'tickfont':{'size':10}}
})
pyo.iplot(
    figure
)

f:id:akatak:20191020123233p:plain

他にも、単純移動平均・RSI・サポートライン・レジスタンスライン・トレンドライン等様々なチャートを追加できます。以下のページを参考にしてみてください。

https://jpoles1.github.io/cufflinks/html/cufflinks.quant_figure.html

その他

上記で利用したJupyter notebookをGistにアップロードしておきました。ただし、Plotlyで描画したグラフはうまく表示されませんでしたが、コード部分のみでも参考になるかもしれませんので、そのままにしておきます。

gist4238ca3c5ba76ecbefb3e5bcc7bdd218

なお、Cufflinksの基本的な使い方は、こちらに簡単に紹介されていますので、参考にしてみてください。

www.sejuku.net

Pythonよる線形回帰分析(2)〜線形回帰分析の留意点

線形回帰分析における留意点をまとめると、以下の通り。
(「Introduction to Statistical Learning」 と「RとPythonで学ぶ[実践的]データサイエンス&機械学習」を参考にしてまとめています。)

1. 残差の分布

(1) 正規性

  • statsmodelsでは、回帰モデルを作成した際に、モデルを格納したオブジェクトに残差の値が記録されているため、これらの値を取り出してヒストグラムや密度プロット等を作成して残差の分布を確認する。
  • 残差について正規分布が仮定できないような場合、そして、正規分布ではなく別のモデルが仮定できるような場合については、線形回帰ではなく一般線形回帰モデルを使う。

(2) 非線形

  • 非線形性を特定するには、残差プロット(横軸:予測値、縦軸:残差)が有効。
  • 残差プロットが非線形性を示している場合には、log X, \sqrt{X},  X^{2} を試してみると良い。

(3) 相関

  • 線形回帰分析における重要な前提は、誤差項が互いに独立しているということ。
  • もし、誤差項が互いに相関がある場合には、本来の標準誤差を低く見積もってしまう傾向がある。結果として、信頼区間や予測値の間隔が本来より狭くなってしまう。
  • 時系列データの場合にこうした相関が発生しやすく、時間の関数として残差をプロットすると良い。
  • 一般論として、線形回帰モデルには、無相関の誤差という前提が極めて重要であるため、こうした相関リスクを軽減するために、実験のデザインを工夫することが大事。

(4) 分散の一定性

  • 線形回帰分析における重要な前提のもう一つが、残差の分散が一定である、というもの。線形回帰モデルにおける標準誤差、信頼区間、仮説検定は全てこの前提に依拠している。
  • もしこの問題に当たったら、対応の一つが、 logY\sqrt{Y} により Y を変換してみること。

2. 多重共線性

  • (多重)共線性とは、2つもしくはそれ以上の説明変数の相関が高い状況をいう。
  • 共線性があると、回帰係数の予測値がより不正確になり、回帰係数の標準誤差が大きくなる。
  • 共線性を見つける簡単な方法は、説明変数の「相関係数マトリクス」を見ること。しかし、この方法で全ての共線性が見つかる訳ではない。それぞれの相関はそれほど高くなくても3つか4つの説明変数により共線性が発生する場合があり、これは多重共線性と呼ばれる。
  • 相関係数マトリクスを見るよりも良いのは、分散拡大係数(VIF: Variance inflation factor)を計算すること。VIFは個々の説明変数ごとに算出される。VIFの最小値は1で、この場合、共線性が全くないことを示す。VIFが10以上(5以上と厳しく設定する場合もあり)であれば、共線性の問題があり、該当する説明変数を削るのが望ましいというのが経験則。

    {\displaystyle
             VIF(\hat{\beta}_j) = \frac{1}{1-R^{2}_{X_j|X_-j}}
   }

  • ここでR^{2}_{X_j|X_-j}は、説明変数X_jを他のすべての説明変数で回帰分析をした場合の決定係数を表す。
  • 共線性が認められた場合の対応として2つ。1つは問題のある説明変数のうち一つを除くこと。もう一つは、共線性が認められる説明変数2つから1つの新たな変数を作成すること。
  • 交互作用項があるモデルは多重共線性が発生しやすい。この場合に、多重共線性を避けるために使われる方法が「中心化(centering)」。中心化とは、元の値を平均値との差で置き換える方法。
  • 交互作用は常に加えるべきというものではない。解釈の複雑さや多重共線性の問題に加えて、オーバーフィッティングの危険性も増大するので、注意が必要。
  • 多重共線性を確認するときの問題は、ダミー変数については1つの説明変数から展開されていること。そこで、ダミー変数もうまく扱えるようなGVIF(GeneralizedVarianceInflationfactor)という指標が提案されている。

3. 外れ値の取扱い

(1) 観測値の外れ値 (Outlier)

  • 外れ値を特定するには、残差プロットを利用すると良い。しかしながら、どの程度大きい場合に外れ値としたら良いか判断が難しい場合あり。
  • その場合には、単なる残差をプロットするのではなく、スチューデント化残差(studentized residuals)という各残差を標準誤差で割った数値をプロットすると良い。スチューデント化残差が3を超えてくれば、外れ値の可能性が高い。
  • また、外れ値がデータ収集や記録の段階でのミスによるものということが明らかなのであれば、観測値から除外することも一つの解決策。しかし、外れ値は、説明変数の欠如を示している可能性もあるので、取り扱いには注意が必要。

(2) 説明変数の外れ値(High Leverage Point)

  • High Leverage Point(高レバレッジ点)とは通常と異なるx値のことをいう。
  • レバレッジ点は、回帰直線への影響が大きい傾向があり、それらを特定することは非常に重要。
  • この高レバレッジ点を見つけるには、単回帰モデルの場合は、以下のleverage statisticを計算する。
    {\displaystyle
       h_i = {\frac{1}{n} + \frac{(x_i - \bar{x})^{2}}{\sum_{i'=1}^{n}(x_{i'} - \bar{x})^{2}}}
    }

  • この数値が大きければ高レバレッジを示している。{h_i} は常に {\displaystyle\frac{1}{n}} から1の間にくる。また、全ての平均レバレッジ{\displaystyle\frac{p+1}{n}}

4. その他

なお、前回に続き、「RとPythonで学ぶ[実践的]データサイエンス&機械学習」の線形回帰分析をPythonで行なっています。具体的には、交互作用項と多重共線性の関係、および中心化による解決策の箇所です。

gistdb00edb8473fd2ec4ba03eeae1e71f45

Pythonによる線形回帰分析(1)〜 Statsmodelsを利用する

Pythonを利用した線形回帰分析について、基本的事項のみ記載した書籍はいろいろあるようですが、実務における課題やその課題に対する対応まで書いた書籍はないんですよね。その場合、R言語を使っているケースが多い気がします。

例えば、「RとPythonで学ぶ[実践的]データサイエンス&機械学習」は実務上の課題や対応方法を具体的に記述していて、非常に良い本なのですが、統計分析(線形回帰分析、クラスタリング、因子分析、主成分分析等)ではR言語が利用されていて、Pythonが利用されているのは機械学習ディープラーニングのみになっています。

RとPythonで学ぶ[実践的]データサイエンス&機械学習

RとPythonで学ぶ[実践的]データサイエンス&機械学習

そこで、この本で利用されている線形回帰分析の事例の一部をPythonで書き直してみました。自分自身への備忘の意味もあり、以下に整理してみます。なお、残差分析やAIC等の確認を行うため、Statsmodelを利用しています。Scikit-learnのLinearRegressionにはそこまでの機能はないようです(予測することに重きを置いている?)

gist8e6eea7dddba8dfe9b30b70e2356d71c

統計検定2級に合格しました

先日受験した統計検定2級の結果が、届いてました。無事、統計検定2級に合格していました。ホームページに正解が掲載されていたので、自己採点していましたが、なんとか合格できて良かった。

実は、試験、案外難しかったんですよね。

受験前には2015年以降の過去問をすべてやって臨んだのですが、過去問にない問題を出題するようにしているのか、年々難しくなっている印象を受けました。

過去問は2015〜2017年の問題を先に行い、最後に2018年の問題を解きました。当初7割前後だった正答率は、試験直前に解いた2018年の問題では正答率は8割台となり、大丈夫かなと高を括っていましたが、なんのなんの、初めて見る問題に時間を取られてしまい、思った以上にてこづった次第です。

こちらが合格証です。

f:id:akatak:20190721113602j:plain

それはそうと、ご参考になるか分かりませんが、僕の勉強法は以下の通り(実際には試行錯誤の側面も多々ありましたが、現在、振り返って整理すると以下の通り)。

(1)統計Web/統計学の時間

結局、こちらのホームページに最もお世話になったと思います。

bellcurve.jp

エクセル統計を作っている会社が、社員の方向けに展開していた統計検定2級向けの勉強会資料を公開していただいているようで、統計検定2級の範囲を過不足なく網羅しています。 初心者にも分かりやすく書かれているのに加えて、各単元の最後には基礎を確認するための練習問題があり、これらを繰り返し勉強すると基礎力がつくと思います。

ただし、統計検定2級の過去問をされた方は分かると思いますが、難しい問題も一定程度出ており、それらの対策をどのように行っていくのかが余裕を持って合格できるかどうかのポイントになるのではないでしょうか。

(2)統計検定2級模擬問題集1〜3

これも統計Webを運営している会社が発行しているもので、残念ながらKindle版しかないようですが、過去問以外に何か統計検定2級対策の問題をするとしたらこの位しか見あたりませんでした。内容としては、統計検定2級の傾向を捉えていると思います。問題数は多くありませんが、過去問以外に問題を解きたいという方の選択肢かなと思います。

統計検定2級 模擬問題集1

統計検定2級 模擬問題集1

(3)統計検定2級公式問題集(過去問)

統計検定のホームページにも過去問は掲載されていますが、問題と正解しか掲載されておらず、解説はありません。したがって、分からない問題の解説を読み、理解を促進するためには、この公式問題集が必要になります。

僕は、冒頭に書きましたように、2015〜2018年までの問題を全て解きました。

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

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

その他、以前の記事にも書きましたが、以下の参考書を使って勉強を行いました。ただし、統計検定2級のためのテキストではないため、検定の範囲を超える内容があったり、選択問題のみの統計検定2級対策としては、そこまで掘り下げなくても良い内容もあったりします。

(4)入門統計学〜検定から多変量解析・実験計画法まで〜

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

入門 統計学 −検定から多変量解析・実験計画法まで−

入門 統計学 −検定から多変量解析・実験計画法まで−

(5)基本統計学第4版

数理的な側面からしっかり説明しているほか、具体例も豊富です。また、標本が少ない場合の検定等でなぜ自由度n-1を使うのかを厳密に説明しています。統計検定2級の範囲を勉強する中で、理論的側面を掘り下げたい場合に、選択的に利用すると良いテキストです。

基本統計学 第4版

基本統計学 第4版

統計検定2級の受験をされる方に参考にしていただけたら幸いです。

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

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