[Flask-SQLAlchemy]レコード編集と論理削除による削除機能の実装

はじめに

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

今回は消耗品の編集/削除機能を作ります。

前回まで

今回やること

前回までで、新規アイテムの登録ができるようになりました。
今回は、登録したアイテムの情報の編集と、削除ができるようにします。
(変更ログの仕組みは次回以降に)

以下の機能を実装します。

  • アイテムリストに、編集/削除ボタンを表示
  • 編集
    • 編集ボタンを押すと編集ページに遷移
    • 編集内容を送信するとアイテム情報を更新できる
  • 削除
    • 削除ボタンを押すとアイテムが削除できる

レコードの更新方法

まず、SQLAlchemyにおけるレコードの更新方法は、

  • レコードを取得
  • 変数を更新
  • レコードをadd->commit

という流れになります。具体例で示すと以下です。

# Itemモデルをidで取得
item = models.Item.searchById(id)

# Itemモデルのname変数を'fuga'に
item.name = 'fuga'

# 変更した内容をDBに反映
try:
    db.session.add(item)
    db.session.commit()
except Exception as e:
    print(e)
    raise e

フロントエンドでは、入力したい値を送れるようにフォームを作り、バックエンドでは値を受け取って上記のような処理を行い、完了ページに飛ばす、というのを作っていきます。

ちなみに、削除はレコードを削除するのではなくstatus列をもたせて値を変更する、いわゆる論理削除の実装で進めます。

編集/削除ボタン追加

先にフロントエンドから作ります。
アイテム一覧の画面でアイテムごとに編集/削除ボタンを追加します。

ついでに、アイテムの名前以外の情報も表示するようにしておきます。

  • tbl/templates/user_page.html
(略)
    <div>
        <ul>
        {% if page.items.count() > 0 %}
            {% for p in page.items %}
                <li>
                    Name: {{ p.name }},
                    Last update: {{ p.updated_at }},
                    Cycle days: {{ p.purchase_cycle_days }},
                    Stock rate: {{ p.stock_rate }},
                    Status: {{ p.status }}
                    <a href="{{ url_for('edit_item', item_id=p.id, uuid=page.uuid) }}">[編集]</a><a href="{{ url_for('delete_item', item_id=p.id, uuid=page.uuid) }}">[削除]</a>
                </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>
(略)

ポイントは以下の箇所です。

<a href="{{ url_for('edit_item', item_id=p.id, uuid=page.uuid) }}">[編集]</a><a href="{{ url_for('delete_item', item_id=p.id, uuid=page.uuid) }}">[削除]</a>

編集ボタンを押すと、編集ページに遷移するedit_itemメソッドを呼び出し、
削除ボタンを押すと、削除完了ページに遷移するdelete_itemメソッドを呼び出します。

どちらも、引数としてはitem_idとページのuuidを渡してます。
理由としては、item_idだけで更新できてしまうと、他のユーザーのアイテムの編集/削除ができてしまうからです。
作成した本人のみが持っている情報(=uuid)をあわせて渡すことで、はじめて更新ができるようにします。

編集ページ作成

続いて、編集ボタンを押すと遷移する編集ページを作ります。

入力フォームの要素は、アイテムの新規登録のフォームとほぼ同じなので、新規登録と編集で要素を流用することを検討します。

includeを使ったテンプレート部品の作成

共通化するフォームを作成します。
まずは、新規登録のフォームを見てみます。

  • tbl/templates/user_page.html
        <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>

これを一部切り出し、item_form.htmlというファイルを新たに作成します。
若干調整も加えます。

  • tbl/templates/item_form.html
    <div>[+]
        <label>品目名: </label>
        <input type="text" class="validate[required]" name="name" id="name"
            {% if submit_word =='Edit item' %} value="{{ item.name }}"{% endif %}/>

        <label>購入サイクル(日数): </label>
        <input type="number" class="validate[custom[number]]" name="purchase_cycle_days" id="purchase_cycle_days" min="1" max="90"
            {% if submit_word =='Edit item' %} value="{{ item.purchase_cycle_days }}"{% endif %}/>

        <label>残量ステータス(0.0〜1.0): </label>
        <input type="number" class="validate[custom[number]]" name="stock_rate" id="stock_rate" min="0.0" max="1.0" step="0.1"
            {% if submit_word =='Edit item' %} value="{{ item.stock_rate }}"{% endif %}/>

        {% if submit_word == 'Edit item' %}
            <input type="hidden" name="item" value="{{ item.id }}" />
            <input type="hidden" name="user_page_id" value="{{ item.user_page_id }}" />
        {% else %}
            <input type="hidden" name="user_page_id" value="{{ page.id }}" />
        {% endif %}
        <input type="submit" value="{{ submit_word }}" />
    </div>

加えた調整としては、

  • placeholderは無くしました。
  • 数値を入れる箇所(購入サイクル、残量ステータス)に数値以外の値が入らないようにinput type="number"に変更しました。
  • {% if submit_word == 'Edit item' %}とある箇所では、submit_wordの値で編集か新規登録かを判別し、表示させる値を切り替えています。
  • 余談ですが、値のバリデーションを行うためにjQuery-Validation-Engineを入れています。

新規登録フォームの置き換え

ファイルを作成したので、新規登録フォームの部分を置き換えます。

  • tbl/templates/user_page.html
        <li>
            <form name="input" class="ui" id="new_item" action="item/create" accept-charset="UTF-8" method="post">
                {% with submit_word = 'Add Item' %}
                {% include "item_form.html" %}
                {% endwith %}
            </form>
        </li>
  • {% include "item_form.html" %}でさきほど作成したitem_form.htmlを読み込みます。
  • {% with submit_word = 'Add Item' %}で、そのitem_form.htmlにsubmit_word変数を渡します(前項で編集か新規登録かを判別する時に使う値です)
  • <form>要素の中で、どのメソッドを呼び出すかを指定しています。

編集フォームの作成

新規登録フォームと同じように、編集ページも作ります。

  • tbl/templates/item_edit.html
{% extends "application.html" %}
{% block body %}
<div>
    <h1>{{ title }} : {{ item.name }}</h1>
    <div>
        <form name="input" class="ui" id="new_item" action="/item/edit/{{item.id}}/{{uuid}}" accept-charset="UTF-8" method="post">
            {% with submit_word = 'Edit item' %}
            {% include 'item_form.html' %}
            {% endwith %}
        </form>
    </div>
</div>
{% endblock %}
  • <form>要素のactionの値に、/item/edit/{{item.id}}/{{uuid}}を入れて、編集完了時に送るリクエスト先を指定しています。

編集ページ遷移/編集結果反映処理の実装

表示側の準備ができたので、バックエンドの処理を書いていきます。実装する機能は

  • 編集ボタンを押した時に編集ページに遷移
  • 編集結果を送信した時に編集内容をDBに書き込み、完了ページに遷移

の2つです。

  • tbl/app.py
(略)
@app.route('/item/edit/<item_id>/<uuid>')
def show_edit_item_page(item_id, uuid):
    """アイテム編集画面に遷移する
    """
    title = 'Item Edit'
    item = models.Item.searchById(item_id)

    return render_template('item_edit.html', title=title, item=item, uuid=uuid)


@app.route('/item/edit/<item_id>/<uuid>', methods=['POST'])
def edit_item(item_id, uuid):
    """アイテムを編集する
    """
    # POSTでない場合は編集ページに遷移
    if request.args == "" or request.method != 'POST':
        return show_edit_item_page(item_id)

    if request.form['user_page_id'] is None: # user_page_idが無い場合は戻す
        return redirect(url_for('index'))

    user_page_id = request.form['user_page_id']

    # UPDATE処理
    item = models.Item.searchById(item_id)

    # item.page_idのuuidと一致するか確認
    if check_is_correct_uuid(item, uuid) is False:
        return "uuid does not match."

    # 値の更新
    if request.form['name']:
        item.name = request.form['name']

    if request.form['purchase_cycle_days']:
        item.purchase_cycle_days = request.form['purchase_cycle_days']

    if request.form['stock_rate']:
        item.stock_rate = request.form['stock_rate']

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

        title = 'Item edit completed'
        url = '/page/' + uuid
        return_msg = 'Back to the page'

        # ページ表示
        return render_template('complete.html', title=title, url=url, return_msg=return_msg)

    except Exception as e:
        print(e)
        raise e


def check_is_correct_uuid(item, uuid):
    """Itemインスタンスから取得したuuidと、送られたuuidが一致するかチェック
    """
    # item_idから該当uuid取得
    user_page_id = item.user_page_id
    user_page = models.UserPage.searchByUserPageId(user_page_id)
    if uuid != user_page.uuid:
        return False
    return True

編集ボタンを押すと、edit_itemメソッドが呼ばれます。
この段階では、何もデータを送っていないので、show_edit_pageメソッドに飛ばされます。これが編集ページになります。

    # POSTでない場合は編集ページに遷移
    if request.args == "" or request.method != 'POST':
        return show_edit_item_page(item_id)

編集結果を送信した時は、check_is_correct_uuidメソッドでuuidの照合をした上で、レコードのUPDATE処理に進みます。

item = models.Item.searchById(item_id)

でレコードを取得し、各種パラメータが送られていれば、

item.xxx = request.form['xxx']

のようにレコードに値を代入しています。

最後に、そのレコードを

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

してDBに書き込んだ上で、完了ページに遷移させています。

完了ページは前回作った汎用的なものを使いまわしているので割愛します。

これでアイテム情報の編集ができるようになりました。

削除の実装

編集ができたら次は削除です。

削除については論理削除、つまり「ユーザーから見たら消えているが、レコードとしては残っている」方式で実装するので、やることは編集とあまり変わりません。

表示部分の処理は編集機能の実装の際にすでに作っているので、バックエンドの処理を書いています。

  • tbl/app.py
(略)
@app.route('/item/delete/<item_id>/<uuid>')
def delete_item(item_id, uuid):
    """アイテムを削除する
    item_idだけで削除できるとまずいので、uuidも渡す
    """
    item = models.Item.searchById(item_id)

    # item.page_idのuuidと一致するか確認
    if check_is_correct_uuid(item, uuid) is False:
        return "uuid does not match."

    item.status = "deleted"

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

        title = 'Item delete completed'
        url = '/page/' + uuid
        return_msg = 'Back to the page'

        # ページ表示
        return render_template('complete.html', title=title, url=url, return_msg=return_msg)

    except Exception as e:
        print(e)
        raise e

アイテム一覧ページで削除ボタンが押されると、delete_itemメソッドにitem_id,uuidが渡されます。
中身の処理はedit_itemとほぼ同じで、更新内容はstatusdeletedにするのみです。

statusの値をactive->deletedに変更し、完了ページに遷移させています。

modelの修正

statusの値を変更しても、今のままでは削除したアイテムも普通に見えてしまうので、Itemモデルを編集します。

  • tbl/models/models.py
(略)
class Item(db.Model):
    """消耗品・アイテムのモデル
    """
(略)
    def searchByUserPageId(user_page_id):
        return db.session.query(Item)\
            .filter(Item.status == 'active')\
            .filter(Item.user_page_id == user_page_id)

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

searchByXXXメソッドの中に、.filter(Item.status == 'active')を追記します。
アイテムを取得する時に、statusがactiveのものだけを取得するようにします。

ここまでで、編集/削除機能が一通り実装できました。

動作確認

編集

  • アイテム一覧ページ

編集を押すと編集ページに遷移します。

  • 編集ページ

値を変更して、送信すると完了ページに遷移します。

  • 編集完了ページ

Back to the pageでアイテム一覧ページに戻ります。

  • アイテム一覧ページ

アイテム情報が更新されていることが確認できます。

削除

さきほどのアイテム一覧ページで、削除を押すと、削除完了ページに遷移します。

  • 削除完了画面

Back to the pageでアイテム一覧ページに戻ります。

  • アイテム一覧ページ

アイテムが消えていることが確認できます。

まとめ

アイテムの編集と削除機能の実装ができました。
これで、基本的なデータ操作の実装は完了となります。

改めて、完了ページ挟むのが面倒だなという気持ちが増しています。
次回以降は、以下の項目に取り組んでいきます。

  • 変更ログの実装
  • ページ遷移をしないでレコード追加・更新ができるようにする

参考