Flask-admin を使った Master/Detail 型テーブルのメンテナンス

# はじめに（動機）
Python の Webフレームワークである Flask には、データベース系アプリケーションを構築する際に必要なマスタメンテナンスの機能を実現するために Flask-admin というパッケージがある。この Flask-admin は簡易的にテーブルごとのメンテナンス画面を作ることができるようになっているが、どこまでカスタマイズできるかを確認することにした。
まずMaster/Detail型のデータの表現ができるかどうかを調べる。
Master/Detail型のデータは親子関係があるデータで、親となるテーブルのレコードに属する複数の子のレコードが存在ようなものである。今回検証には「部署」とその部署に所属する「従業員」というものを作った。

 * 部署テーブル (Department)
   * 部署コード
   * 部署名
 * 従業員テーブル (Employee)
   * 所属する部署
   * 名前
   * 苗字
   * 生年月日
   * 性別

Flask-admin で実現したいことは、部署の一覧からその部署に所属する従業員だけの一覧を表示すること。テーブル単位に一覧を表示し個々のレコードを編集したり削除したりすることは Flask-admin で非常に短いコードでできるが、それ以上のことをしようとするといくらかのカスタマイズコードが必要になる。結論から言うと、マスター/ディテールのデータの表現はそんなに多くないコードで実現できる。ただこのやり方について解説したものは Stack-overflow も含め見当たらなかったので手順をここに示すことにする。

まずは Flask-admin のドキュメントの Getting Started という章のものをほとんどそのまま、想定しているモデルに適用したソースを示す。 ソースはコントローラとビューを含む app.py とモデルを定義した model.py の２つに分かれている。

```python:app.py
"""flask-admin の拡張についての実験
flask-admin を使って master/detail 型のマスタメンテナンスを実現する
STEP0: ほぼ Getting Started そのまま
"""

from flask import Flask
from flask_admin import Admin

from flask_admin.contrib.sqla import ModelView
from model import (
    Department,
    Employee,
    create_session
)

app = Flask(__name__)

# テーマ切り替え https://bootswatch.com/
app.config['FLASK_ADMIN_SWATCH'] = 'United'
# データを更新する際に必要なキー
app.secret_key = 'XH[chp??0hfdklxhQddsx'

admin = Admin(
    app,
    name='Flask-admin laboratory',
    template_mode='bootstrap4',
)

session = create_session()

admin.add_view(ModelView(Department, session))
admin.add_view(ModelView(Employee, session))

if __name__ == '__main__':
    app.run(port=5001, debug=True)

```

```python:model.py
# coding: utf-8
"""flask-admin-labo 用のモデル
"""
import os

from sqlalchemy import Boolean, CHAR, Column, Date, DateTime, Float, ForeignKey, Integer, String, Table, Text, UniqueConstraint, text
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker

Base = declarative_base()
metadata = Base.metadata

_databese_file = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'flask-admin-labo.db')
_engine = create_engine('sqlite:///' + _databese_file, convert_unicode=True)

def create_session():
    sess = scoped_session(sessionmaker(autocommit=False,autoflush=False,bind=_engine))
    _init_db(sess)
    return sess

def _init_db(sess):
    try:
        rs = sess.query(Department).all()
    except Exception as ex:
        metadata.create_all(bind=_engine)


class Department(Base):
    __tablename__ = 'department'

    id = Column(Integer, primary_key=True, autoincrement=True)
    code = Column(String(10), nullable=False, unique=True, comment='部署コード')
    name = Column(String(64), comment="部署名")
    created_at = Column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
    updated_at = Column(DateTime)

    def __repr__(self):
        return self.name

class Employee(Base):
    __tablename__ = 'employee'

    id = Column(Integer, primary_key=True, autoincrement=True)
    first_name = Column(String(64), nullable=False, comment='名前')
    last_name = Column(String(64), nullable=False, comment='苗字')
    department_id = Column(ForeignKey('department.id', ondelete='SET NULL', onupdate='CASCADE'))
    gender = Column(String(1), nullable=True, comment='性別')
    birth_date = Column(Date, nullable=True, comment="生年月日")
    created_at = Column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
    updated_at = Column(DateTime)

    department = relationship('Department')

    def __repr__(self):
        return "%s, %s" % (last_name, first_name)
```

上記コードを実行して表示した画面例を以下に示す。(これはまだMaster/Detailに対応前の画面である）
### 部署一覧
![ss00-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1218194/043909d7-268e-dc14-58bb-3f58e78374bd.png)
### 従業員一覧
![ss00-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1218194/f5b5e80c-6ac6-50ed-0c7d-f289aa120222.png)
### 従業員変更
![ss00-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1218194/48f4ff86-a0d9-1077-af76-7e82c4db0baf.png)

さて、ここからが本番となるが、まずやりたいことを整理したい。

 1. 従業員一覧の画面には部署一覧で選択された部署から入る。
 1. 画面トップに表示しているメインメニューからは従業員一覧を表示する項目を外す。
 1. 従業員変更（あるいは作成）の画面の部署(Department)の項目は、指定された部署の従業員の更新を行っているという意味になるので省く。

まず１番目の部署一覧の行を選択して、そこに所属する従業員を表示するために、行の先頭に表示されているアイコンに従業員表示のためのアイコンを設け、それをクリックした時にその部署に所属する従業員を表示することにする。

コントローラ・ビューのコードの変更したソースを以下に示す。なお、モデルのコードに変更は入らない。

```python:app.py
"""flask-admin の拡張についての実験
flask-admin を使って master/detail 型のマスタメンテナンスを実現する
STEP1: 部署リストに行アクションとして従業員一覧表示ボタンを追加
"""

from flask import Flask, url_for, redirect
from flask_admin import Admin, expose
from flask_admin.contrib.sqla import ModelView
from flask_admin.model.template import TemplateLinkRowAction
from model import (
    Department,
    Employee,
    create_session
)
from sqlalchemy import func

app = Flask(__name__)

# テーマ切り替え https://bootswatch.com/
app.config['FLASK_ADMIN_SWATCH'] = 'United'
# データを更新する際に必要なキー
app.secret_key = 'XH[chp??0hfdklxhQddsx'

admin = Admin(
    app,
    name='Flask-admin laboratory',
    template_mode='bootstrap4',
)

session = create_session()

class DepartmentModelView(ModelView):
    "部署マスタビュー"
    list_template = "master_list.html"  # 従業員リストを表示するボタン用
    column_extra_row_actions = [  # Add a new action button
        TemplateLinkRowAction("utils.employee_button", "Detail"),
    ]
    def __init__(self, session):
        super(DepartmentModelView, self).__init__(Department, session)

class EmployeeModelView(ModelView):
    "従業員マスタビュー"

    column_exclude_list = ['department']    # 一覧表示から部署のカラムを撤去
    form_excluded_columns = ['department']  # フォームから部署の欄を撤去

    def __init__(self, session):
        super(EmployeeModelView, self).__init__(Employee, session,
                                                menu_class_name='d-none')

    @expose("/<department_id>", methods=("GET",))
    def view(self, department_id):
        """新設した詳細表示ボタンが押されたときここに入る
        department_id には部署の表示行にhiddenフィールドに入っている
        部署id が入る
        """
        self.department_id = department_id
        return self.index_view()

    def get_query(self):
        "部署IDでフィルタをかけたクエリを行う（行表示用）"
        return self.session.query(Employee) \
                    .filter(Employee.department_id == self.department_id) \
                    .order_by(Employee.first_name,
                              Employee.last_name,
                    )
    
    def get_count_query(self):
        "部署IDでフィルタをかけたクエリを行う（件数表示用）"
        return self.session.query(func.count('*')) \
                    .select_from(Employee) \
                    .filter(Employee.department_id == self.department_id)

    def on_model_change(self, form, model, is_created):
        "モデルの更新(登録)直前に呼び出す"
        model.department_id = self.department_id

admin.add_view(DepartmentModelView(session))
admin.add_view(EmployeeModelView(session))

if __name__ == '__main__':
    app.run(port=5001, debug=True)
```

「詳細表示」用のボタンを部署一覧画面に仕組むためにはビューのテンプレートの変更と、それに付随する html ファイル (Jinja2のテンプレートファイル) が必要になる。
これらのファイルは templates というディレクトリに置く。

```html:utils.html
{% macro employee_button(action, row_id, row) %}
<a class="icon" href="{{ get_url('employee.view', department_id=get_pk_value(row)) }}">
  <span class="glyph-icon fa fa-list-alt"></span>
</a>
{% endmacro %}
```
これが部署一覧のテンプレートファイル。サンプル元のままの場合には flask-amin パッケージ内の templates ディレクトリにある admin/model/list.html が使われているが、代わりに以下のファイルを使うように app.py の DepartmentModelView クラスで定義をしている。

```html:master_list.html
{% extends 'admin/model/list.html' %}
{% import 'utils.html' as utils with context %}
```
### Master/Detail に対応した部署一覧画面
左側に表示されているアイコンのごみ箱の右横に「詳細表示」のボタンが表示されている。
またヘッダ部に表示されていた「Employee」が消えている。
![ss01-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1218194/269c81be-5708-c2bc-5457-d273828f82a8.png)
### マスタディテイルに対応した従業員一覧
表示されている従業員は親の部署（この場合は「企画部」）に所属している従業員のみ
またオリジナルでは「Department」カラムが表示されていたが、ここでは表示されていない。

![ss01-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1218194/cfcb87b8-74a8-4244-2733-887f4cabb4a3.png)

# まとめ

Flask-admin では簡単にデータベースのテーブルのメンテナンス画面を作ることができる。ユーザがアプリケーションのデータの構造を直感的にわかりやすいようにマスターメンテナンス画面を作成するために Master/Detail 型の表現もある程度実現できることがわかった。Flask-admin のマニュアルにもある程度のことは書いてあるが、実際に自分がやりたいことができるかどうかはマニュアルから読み解くことは難しく、実際に作ってみないとわからなかった。
尚、本検証に使ったモジュールのバージョンは以下の通り

| モジュール | バージョン |
|:----------:|:--------:|
| Flask | 1.1.2 |
| Flask-admin | 1.5.7 |
今回実験をした全ソースはこちら
https://github.com/kmori20201226/flask-admin-labo