[Flask-SQLAlchemy]レコードの登録機能の実装

はじめに

PythonのWebアプリケーションフレームワークFlaskを使って、「消耗品買い物リスト」を作ってみるシリーズ第11回目です。

今回は消耗品の登録機能を作ります。

前回まで

今回やること

本丸である消耗品の登録/更新機能を作っていきます。
自分のページ上で、

  • 消耗品一覧の表示
  • 消耗品の登録
  • 消耗品の更新

が行えるようにします。

流れ

  • アイテム管理機能の構想
  • テーブル設計
    • 実装機能を詳細に書き出す
    • 変更ログをどう残すか
    • ER図の作成
  • モデル作成
    • モデル間のrelationの設定
    • マイグレーション
  • 登録機能実装
    • フォーム作成
    • ルーティング/登録処理
    • 完了ページ作成 ← 今回はここまで
  • 更新機能実装
    • 更新ページ作成
    • ルーティング
    • 更新処理

アイテム管理機能の構想

まず、消耗品買い物リストでやりたいことを整理しておきます。

「消耗品の買い忘れをなくしたい」という課題を解決するサービスですが、解決アプローチとしては「消耗品の購入サイクルと、記録された残量目安とその日時から、現時点での残量を推定し、そろそろ買うべきものを教えてくれる」というものになります。

例えば、10日ごとに買い換える消耗品があるとします。
購入した日に残量100%と入力すると、5日後に確認したときには残量50%、8日後に確認したときには残量20%となり、残量20%を切ると「買い時」として教えてくれる、みたいなイメージです。

その先には、残量ステータスのより正確な予測、利用実績から最適な買い時を提案などもできると良いのでは、と考えています。

この、ざっくりとしたやりたいことを実現するためのテーブル構造を考えていきます。

テーブル設計

実装機能を詳細に書き出す

サービスを利用するユーザーの行動を時系列に並べて、具体的に必要になりそうなデータを洗い出していきます。
以降、消耗品を「アイテム」と呼称しています。

  • アイテムを登録する
    • 品目名を入力
    • 品目カテゴリを入力
    • 購入サイクルを入力
      • N日ごと
    • 残量ステータスを入力
      • 0.0 – 1.0
  • アイテムの残量ステータスを変更する
    • 残量ステータスを入力
  • アイテムの設定を変更する
    • 品目/品目カテゴリ/購入サイクルを入力

こんな流れですね。

変更ログをどう残すか

残量ステータスの変更ログは残しておきたいので、変更ログにも思いを馳せます。

シンプルに、レコードの更新時には、変更内容をそのままログテーブルに追記するという方式を取ります。
よく変更されるであろう残量ステータスと、それ以外の品目名、カテゴリ、購入サイクルなどの項目は、別のテーブルに分けた方が良いのではないか、という論点もあります。
なのですが、

  • テーブルを分けると実装・管理が複雑になりそう
  • 集計時にまとまっていた方が使いやすそう

という理由でシンプルな残し方にします。

ER図の作成

書き出した実装機能と、変更ログの残し方を踏まえ、ER図を書きます。

ざっくり役割:

  • user_pages : ページ(リスト)の情報を格納
  • items : 消耗品アイテムの情報を格納
  • item_categories : 消耗品アイテムのカテゴリー情報を格納
  • item_update_logs : itemsの更新履歴を保存

これをもとに、モデルのコードを書いていきます。

モデル作成

  • tbl/models/models.py
from datetime import datetime
from tbl.database import db
import uuid as _uuid

class UserPage(db.Model):
    """ユーザーページ(=リスト)のモデル
    """
    __tablename__ = 'user_pages'
    __table_args__ = {'extend_existing': True}

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    uuid = db.Column(db.String(255), index=True, unique=True, nullable=False)
    status = db.Column(db.String(10), nullable=False)
    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)

    def __init__(self):
        self.uuid = str(_uuid.uuid4())
        self.status = 'active'

        self.items = Item.searchBy(self.id)

    def searchByUuid(uuid):
        inst = db.session.query(UserPage)\
            .filter(UserPage.uuid == uuid)\
            .one()

        inst.items = Item.searchByUserPageId(inst.id)
        return inst

    def searchByUserPageId(user_page_id):
        return db.session.query(UserPage)\
            .filter(UserPage.id == user_page_id)\
            .one()


class Item(db.Model):
    """消耗品・アイテムのモデル
    """
    __tablename__ = 'items'
    __table_args__ = {'extend_existing': True}

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    user_page_id = db.Column(db.Integer, db.ForeignKey('user_pages.id', onupdate='CASCADE', ondelete='CASCADE'))
    item_category_id = db.Column(db.Integer, db.ForeignKey('item_categories.id', onupdate='CASCADE', ondelete='CASCADE'))
    name = db.Column(db.String(255), nullable=False)
    purchase_cycle_days = db.Column(db.Integer, default=30) # 購入頻度
    stock_rate = db.Column(db.Float, default=1.0) # 現在の在庫割合

    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)

    def __init__(self, user_page_id, item_category_id=1, name="Not set", purchase_cycle_days=30, stock_rate=1.0):
        self.user_page_id = user_page_id
        self.item_category_id = item_category_id
        self.name = name
        self.purchase_cycle_days = purchase_cycle_days
        self.stock_rate = stock_rate


    def searchByUserPageId(user_page_id):
        return db.session.query(Item)\
            .filter(Item.user_page_id == user_page_id)

    def searchById(id):
        return db.session.query(Item)\
            .filter(Item.id == id)\
            .one()


class ItemCategory(db.Model):
    __tablename__ = 'item_categories'
    __table_args__ = {'extend_existing': True}

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    name = db.Column(db.String(255), nullable=False)
    description = db.Column(db.Text)

    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)


class ItemUpdateLog(db.Model):
    __tablename__ = 'item_update_logs'

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    item_id = db.Column(db.Integer, db.ForeignKey('items.id', onupdate='CASCADE', ondelete='CASCADE'))
    item_category_id = db.Column(db.Integer, db.ForeignKey('item_categories.id', onupdate='CASCADE', ondelete='CASCADE'))
    name = db.Column(db.String(255), nullable=False)
    purchase_cycle_days = db.Column(db.Integer, default=30) # 購入頻度
    stock_rate = db.Column(db.Float, default=1.0) # 現在の在庫割合

    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)

モデル間のrelationの設定

モデル間のrelationの設定には、db.ForeignKey()を使います。
例として、itemsにはitem_cateogriesのidを格納していますが、その関係性をコードで記述すると以下のようになります。

...
user_page_id = db.Column(db.Integer, db.ForeignKey('user_pages.id', onupdate='CASCADE', ondelete='CASCADE'))
...

ちなみにonupdate/ondelete=CASCADEは、参照しているレコードが値が変わった/削除されたときの挙動を規定しています。
CASCADEの場合、参照先のレコードの値が変わった/削除された時に、参照元のレコードも変更もしくは削除するという動きになります。

マイグレーション

ここまで書いたら

flask db migrate
flask db upgrade

で書いたモデルをスキーマに反映させます。

登録機能実装

モデルを作成したので、アイテム登録機能を実装します。

具体的には、

  • ユーザーページ(リスト)のトップページではアイテムリストが表示される
  • 新規アイテムの登録フォームを入力すると、アイテムリストに加わる

という動きになります。

フォーム作成

まずは、ユーザーページのテンプレートを作り込みます。

  • アイテムリストがあるなら表示。なければ表示しない。
  • アイテム追加フォームを表示。

という作りにします。

  • tbl/templates/user_page.html
{% extends "application.html" %}
{% block body %}
<div>
    <h1>{{ title }}</h1>
    <div class="field">
        <p>My id is {{ page.id }}, uuid is {{ page.uuid }}.</p>
        <p>Items : {{ page.items.count() }}</p>
    </div>
    <div>
        <ul>
        {% if page.items.count() > 0 %}
            {% for p in page.items %}
                <li>{{ p.name }}</li>
            {% endfor %}
        {% else %}
            <p>No item set yet.</p>
        {% endif %}
        <li>
            <form name="input" class="ui" id="new_item" action="item/create" accept-charset="UTF-8" method="post">
                <div>[+]
                    <label>Name: </label><input type="text" name="name" id="name" placeholder="Input item name..." />
                    <label>Cycle days: </label><input type="text" name="purchase_cycle_days" id="purchase_cycle_days" placeholder="1..90" />
                    <label>Stock rate: </label><input type="text" name="stock_rate" id="stock_rate" placeholder="0.0..0.1" />
                    <input type="hidden" name="user_page_id" value="{{ page.id }}" />
                    <input type="submit" value="Add item" />
                </div>
            </form>
        </li>
    </ul>
    </div>
</div>
{% endblock %}
  • このページには、UserPageインスタンスをpageに入れて渡しています。
  • UserPage.itemsには、そのページと紐付いたアイテム一覧が入っているので、for p in page.items の箇所でアイテムリストを表示しています。
  • formタグで囲んでいる箇所が新規アイテムの追加箇所です。actionで呼び出すURLを定義しています。次項ではこのURLにリクエストを投げたときの挙動を書いていきます。

ルーティング/登録処理

アイテム追加時の挙動を作るために、ルーティングを設定し、登録処理を書いていきます。

  • tbl/app.py
@app.route('/page/<uuid>/item/create', methods=['POST'])
def create_new_item(uuid):
    """新規アイテムを作成する
    """
    if request.args == "":
        return "Error"

    if request.method != 'POST':
        return # 本来はエラー返したい

    # 作成
    user_page_id = request.form['user_page_id']
    name = request.form['name']
    purchase_cycle_days = request.form['purchase_cycle_days']

    item = models.Item(user_page_id, name=name, purchase_cycle_days=purchase_cycle_days)

    try:
        db.session.add(item)
        db.session.commit()

        title = "新規アイテムを追加しました"
        return_msg = "Back to the page."

        url = '/page/' + uuid

        # ページ表示
        return render_template('complete.html', title=title, url=url, return_msg=return_msg)
    except Exception as e:
        print(e)
        raise e

完了ページ作成

完了ページは、以前作成した新規ページ作成完了用のページ(complete.html)を汎用的にカスタマイズして使います。
title, url, return_msgに任意の文字列を格納する作りにすることで一定汎用的に使えるようにしています。

  • tbl/templates/complete.html
{% extends "application.html" %}
{% block body %}
<div>
    <h1>{{ title }}</h1>
    <div class="field">
        <a href='{{ url }}'>{{ return_msg }}</a>
    </div>
</div>
{% endblock %}

ただ、完了ページを都度挟むのは面倒なので、最終的には完了ページなしで動くように作り変えていきます。

挙動確認

新規のユーザーページ。まだアイテムが追加されていません。

フォームを入力して、Add itemを押すと…

確認ページに遷移し、

アイテムリストのページに戻るとアイテムが追加されています

確認ページが思った以上に面倒くさいですね。早めに改修しよう…

最後に

表示部分はこれから作り込む必要がありますが、まずは新規アイテムの登録ができるようになりました。
次回は、登録したアイテムの更新ができるようにしつつ、変更ログを残す仕組みも実装していきます。
その後、カテゴリ設定ができていなかったり、入力フォームが使いづらかったりするので、そのあたりの改善もやっていきます。

参考