Django Admin を既存の(PHPなどの) ECサイトに適用する手順と機能紹介

弊社内で、EC-CUBE という PHPのフレームワーク (ECサイト構築パッケージ) で動いているECサイトに対し、機能の追加のため、モバイルAPIや外部ツールからのデータ登録連携をするためのAPI、それとスタッフの閲覧・運用のためのAdmin機能を開発することになりました。


# 従来の Admin を変える

EC-CUBE にも Admin はありますが、機能を増やす際に書くコード量は比較的多いと感じており、このまま使い続けるのも良くないと思ったため、今回は、モバイルAPIと Admin を Django で新たに作ることにしました。

今更 Django という感じはしますが、少ないコード量で作れる Admin は使いやすく、Form, 認証やPermission, テンプレートエンジン、ビューコントローラなど Django のコア機能がうまく調和したとても良いアプリケーションだと思っており、私は昔からかなり好きです。

一方で、操作対象はリレーショナルDBに限定されますが、お客様の購入を管理・集計するECシステムでは、リレーショナルDBがまだまだ主役であると思っています。普通に使っていればレースコンディションも発生しませんし、ミューテックスを自前で用意する必要もありません。ACID最高ですね。

私自信、RDBを好きだというのは自覚しており、日常生活で見かけたものが、RDBのテーブルやレコードに見えることがあります。世間では、日常生活で見かけたものを CSS の Flex で自動的に頭の中でレイアウトしてしまう人や、色々なものが関数や学習教師やFlutterのウィジェットやprologの命題に見えてしまう方がいるようですが、そういったヤバい方に比べると、飲み物の自動販売機であったり、私が行ってるテニススクールに参加されている他のお客さんがRDBのレコードに見えたりする自分はまだまだ大丈夫だなと感じたりします。ACID最高ですね。

# Django で複数のDB接続を扱えるようにする

Djangoでは複数のDB接続を同時に使えます。旧システムのマイグレーションを行う場合は、旧システムの db は新しく作る default のデータベースとは別にしたほうが扱いやすくなると思います。

例えば、settingsでこのようにしてDB接続を分けておきます。default が今回新たに作ったDBスキーム、legacy が古くからあるスキームです。作ったら、`./manage.py migrate` して、default のDBを作っておきます。

```python3:settings
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'my_default_scheme',
        ...
    },
    'legacy': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'old_system_scheme',
        ...
    },
}

class MyDBRouter:
    legacy_system_app = {'legacy_system'}

    def db_for_read(self, model, **hints):
        return 'legacy' if model._meta.app_label in self.legacy_system_app else 'default'

    def db_for_write(self, model, **hints):
        return 'legacy' if model._meta.app_label in self.legacy_system_app else 'default'

    def allow_syncdb(self, db, model):
        return db != 'legacy'

DATABASE_ROUTERS = ['xxx.settings.MyDBnRouter']

```


# 既にあるリレーショナルDBのテーブル

既に動いているシステムのため、DBには既にスキーマがあり、データが入っています。

例えば、このようなテーブルががあるとします。実際のものより大分簡略化しています。

### dtb_product (商品)
- product_id (int) 商品ID Primary Key Auto Increment
- name (varchar) 商品名
- price (int) 金額

### dtb_order (受注)
- order_id (int) 受注ID Primary Key Auto Increment
- total (int) 合計金額
- status (int) 受注の状態 … mtb_order_status への関連

(実際には、他に配送住所のフィールドなどがあります)

### dtb_order_detail (受注詳細)
- dtb_order に対して 1:N の関係となる
- order_id(int) dtb_order.order_id への関連。外部キー制約なし
- product_id(int) … dtb_product.product_id への関連。外部キー制約なし
- price(int)
- quantity(int)

### mtb_order_status (受注ステータス)
- id (int) 受注ステータスID
- name (varchar) ステータス名

# DBからモデルコードを自動作成する

Djangoでは、DBのスキーマを読み取って自動的にモデルコードを作成する機能があります。

```bash
./manage.py inspectdb --database=legacy > legacy_system/models.py
```

みたいな感じで、コードを出力します。

下記のようなコードが出力されます。

```python3:legacy_system/models.py
class DtbProducts(models.Model):
    product_id = models.AutoField(primary_key=True)
    name = models.CharField(max_length=100, blank=True, null=True)
    price = models.IntegerField(blank=True, null=True)

    class Meta:
        managed = False
        db_table = 'dtb_products'


class DtbOrder(models.Model):
    order_id = models.AutoField(primary_key=True)
    total = models.DecimalField(
        max_digits=10, decimal_places=0, blank=True, null=True)
    status = models.IntegerField()

    class Meta:
        managed = False
        db_table = 'dtb_order'


class DtbOrderDetail(models.Model):
    order_id = models.IntegerField()
    product_id = models.IntegerField()
    price = models.DecimalField(
        max_digits=10, decimal_places=0, blank=True, null=True)
    quantity = models.DecimalField(
        max_digits=10, decimal_places=0, blank=True, null=True)

    class Meta:
        managed = False
        db_table = 'dtb_order_detail'


class MtbOrderStatus(models.Model):
    id = models.SmallIntegerField(primary_key=True)
    name = models.CharField(max_length=100, blank=True, null=True)
    rank = models.SmallIntegerField()

    class Meta:
        managed = False
        db_table = 'mtb_order_status'

```
実際は、200モデルほど自動で作られました。

# Auto Increment PK の追加
今回の dtb_order_detail テーブルには、Auto Increment の PKフィールドがありません。その場合 Djangoでは扱えないためフィールドを追加しておきます。

```sql
ALTER TABLE `dtb_order_detail` ADD `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY
```

# Meta.managed を変更し、ユニットテストができるようにする

先程自動生成されたコードを見ると、Meta クラスの managed が False となっています。
これは、 ./manage.py makemigrations などDB操作の対象としないということですが、このままだとこれらのモデルを使ったユニットテストの際にテーブルが無く失敗します。そのため、ユニットテストの時はのここを True にしたいです。

例えば私はこのようにしてます。

settings.py などで

```python3:settings
import sys


def in_test():
    return 'test' in sys.argv or 'jenkins' in sys.argv

FALSE_EXCEPT_TEST = in_test()
```
このようにしておいて、

```python3:legacy_system/models.py
class DtbOrderDetail(models.Model):
    ...

    class Meta:
        managed = settings.FALSE_EXCEPT_TEST
        ...
```
これで、ユニットテストの時にはテーブルが作られるようになります。

# モデルリレーションの設定
先程の DtbOrder と DtbOrderDetail の 1:N の関係のように、データ的には関連があるが RDB の ForeignKey制約 制約は無い、というテーブルの場合、 manage.py inspectdb では、モデルの中でフィールドを ForeignKey のコードは出力してくれません。

そのため、手動で ForeignKey を設定します。DB上でのFK制約はつけずに、しかしアプリケーション側ではFKの関連性があると認識させます。Django ではこのように書きます。

```python3:legacy_system/models.py
class DtbOrderDetail(models.Model):
    order = models.ForeignKey(
        DtbOrder, on_delete=models.PROTECT,
        to_field='order_id', db_constraint=False,
    )
    product = models.ForeignKey(
        DtbProducts, on_delete=models.PROTECT,
        to_field='product_id', db_constraint=False,
    )
```
このようにすれば、DBのFK制約が無い中でも Django では ForeignKey として機能させ、 .select_related や後述の Adminでの検索などのモデルリレーション機能が使えるようになります。

# その他モデルの修正

`Meta.verbose_name`, `verbose_name_plural`, フィールドの `verbose_name`, モデルの `__str__` 等作って起きましょう

```python:legacy_system/models.py
class DtbOrderDetail(models.Model):
    order = models.ForeignKey(
        DtbOrder, on_delete=models.PROTECT,
        to_field='order_id', db_constraint=False,
    )
    product = models.ForeignKey(
        DtbProducts, on_delete=models.PROTECT,
        to_field='product_id', db_constraint=False,
    )
    price = models.DecimalField(
        verbose_name='金額',
        max_digits=10, decimal_places=0, blank=True, null=True)
    quantity = models.DecimalField(
        verbose_name='数量',
        max_digits=10, decimal_places=0, blank=True, null=True)

    class Meta:
        verbose_name = verbose_name_plural = '受注詳細'
        managed = settings.FALSE_EXCEPT_TEST
        db_table = 'dtb_order_detail'

    def __str__(self):
        return f'受注詳細:{self.order_id}-{self.product_id}'

```

# Admin ディレクトリの作成
DjangoのAdminは機能が豊富で、View, Form, Adminアクションをどんどん追加していくことになると思います。ファイルが増えても良いように、admin用にディレクトリを一つ作っておくと後々きれい使えると思います。



```python3:admin/__init__.py
from . import models  # NOQA
```

# ModelAdminの作成
まずはコード

```python3:admin/models.py
from ..models import Product, DtbOrder, DtbOrderDetail

class DtbOrderDetailInline(admin.TabularInline):
    model = DtbOrderDetail
    extra = 0
    raw_id_fields = (
        'order',
        'product',
    )

@admin.register(DtbOrder)
class DtbOrderAdmin(admin.ModelAdmin):
    list_display = (
        'order_id',
        'total',
        'status',
    )
    list_display_links = (
        'order_id',
    )
    search_fields = (
        'order_id',
        'dtborderdetail__dtbproduct__name',
    )
    ordering = (
        '-order_id',
    )
    list_filter = (
        'status',
    )
    inlines = (
        DtbOrderDetailInline,
    )


@admin.register(DtbOrderDetail)
class DtbOrderDetailAdmin(admin.ModelAdmin):
    list_display = (
        'id',
        'order_id',
        'product',
        'price',
        'quantity',
    )
    list_display_links = (
        'id',
        'order_id',
    )
    search_fields = (
        'order_id',
        'product_id',
        'product__name',
    )
    raw_id_fields = (
        'order',
        'product'
    )

```

シンプルに Django のModelAdmin を書くとこのようになります。

いくつかポイントを紹介します。

## 自動でjoinする

OrderAdminの中の status フィールドは ForeignKey ですが、OrderAdminのリストを表示する際は内部で mtb_status テーブルを JOIN し、出力結果から MtbStatus モデルを内部で作成して `__str__` メソッドを実行する、という動作をします。無駄なSQLは出ずに快適に表示されます。
![変更する_受注_を選択___Django_サイト管理.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/18231abc-e0de-6b1b-e8b1-a014149bebba.png)

## list_filter
list_filter に入れたフィールドは、ForeignKey のレコードを全件取得して自動でフィルターウィジェットを作ります。

![変更する_受注_を選択___Django_サイト管理.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/3d2c1bfd-78d7-127a-4913-83020622822e.png)

# search_fields

search_fields に入れたフィールドは文字列検索の対象になります。

今回、`dtborderdetail__dtbproduct__name` としましたが、これは関連する dtb_order_detail と dtb_product のテーブルをそれぞれ適切に JOIN し、name フィールドを検索して該当した関連のある dtb_order を結果表示します。自分で JOIN 文を書かなくて良いのでとても楽です。

![変更する_受注_を選択___Django_サイト管理.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/30b294c8-8701-346c-d124-13b33db4b106.png)


## inlines
ModelAdmin の inlines フィールドに

```python
    inlines = (
        DtbOrderDetailInline,
    )
```
このように、TabularInline もしくは StackedInline を継承したモデルを指定すると、ForeignKey のリレーションがあるモデルを1つのAdmin ページ内で操作できるようになります。

##### TabularInline
フィールドを横に並べたテーブルの表示になります
![Screenshot 2019-12-07 22.29.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/d35fdd1d-4834-6525-f758-cb95e1b02574.png)

##### StackedInline
フィールドを縦に並べたテーブルの表示になります
![Screenshot 2019-12-07 22.30.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/140399ea-6551-e44e-d487-d5ed6cfe645d.png)

## raw_id_fields

Django の Admin は、デフォルトでは ForeignKey のフィールドを HTML の Select で描画しようとします。
描画時に全件取得しようとするため、今回の Product への関連のようにレコード数が膨大になるとSQLの結果取得とHTML出力が現実的ではない時間・量になります。

関連先のレコード数が多い ForeignKey の場合、Adminで raw_id_fields に指定しておけば、全件取得はしないため問題を回避できます。

今回の status のように、関連先が数件のレコードであれば、raw_id_fields にせずに select ウィジェットを表示させるのが良いでよう。

# Admin の URLルーター名

@admin.register デコレータで登録した ModelAdmin は、`{% url 
xxx %}` テンプレートタグや reverse() 関数に使えるURLルーター名が自動的に登録されます。

リストページは `admin:<app名>_<モデル名>_changelist`, 詳細(編集)ページは `admin:<app名>_<モデル名>_change` です。

たとえば、`reverse('admin:legacy_system_dtbproduct_changelist')` や

`<a href="{% url 'admin:legacy_system_dtbproduct_change' 12 %}">商品12を変更</a>`

このような使い方になります。
 
# Admin テンプレート変更

特定のAdminページのテンプレートを修正したい場合、Djangoのテンプレートエンジンのテンプレートファイル選択機能を使って乗っ取ることができます。


テンプレートファイルの検索順は、 `django/contrib/admin/templatetags/base.py` を見ると

```python:django/contrib/admin/templatetags/base.py
    def render(self, context):
        opts = context['opts']
        app_label = opts.app_label.lower()
        object_name = opts.object_name.lower()
        # Load template for this render call. (Setting self.filename isn't
        # thread-safe.)
        context.render_context[self] = context.template.engine.select_template([
            'admin/%s/%s/%s' % (app_label, object_name, self.template_name),
            'admin/%s/%s' % (app_label, self.template_name),
            'admin/%s' % (self.template_name,),
        ])
        return super().render(context)
```
このようになっており、特定のモデルのみにテンプレートの変更を適用したい場合は、上記命名ルールでファイルを作っておけば実現できます。

例えば、先程の DtbOrderAdmin のリストページの上部に注意文言を表示したいとしたら、`legacy_system/templates/admin/order/dtborder/change_list.html` をこのように作ります。

```django:legacy_system/templates/admin/order/dtborder/change_list.html
{% extends 'admin/change_list.html' %}

{% block content %}
    <p>このモデルの注意文言</p>
    {{ block.super }}
{% endblock %}
```
![変更する_受注_を選択___Django_サイト管理.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/46bb1910-0d7d-00f5-8214-30f095d9137e.png)

テンプレートは、オブジェクト指向言語のように継承とオーバーライドができるため、変更したいブロックをオーバーライドし、追記部分だけ書いてスーパーテンプレートのブロックを呼び出すことで、追記部分だけに集中できます。

テンプレート内のすべてのウィジェットで、先程の命名ルールでのテンプレート選択ができるので、例えば `django:legacy_system/templates/admin/order/dtborder/pagination.html` というファイルを作っておけば、パジネーションウィジェットのテンプレートをDtbOrderのリスト表示のときのみ差し替えることもできます。

# Adminアクションを追加する

![Screenshot 2019-12-08 0.48.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/41a9d075-60eb-2a1c-41a5-676218f71cb4.png)

チェックボックス + プルダウンで指定できるアクションを自作して追加できます。

### admin/actions.py の作成
```python:admin/actions.py
from django.contrib import messages

def send_fcm_message(modeladmin, request, queryset):
    for object in queryset.all():  # type: FcmMessage
        object.send()

        m = 'メッセージ「{}:{}」を本番送信しました'.format(
            object.id,
            object.body
        )
        messages.success(request, m)


send_fcm_message.short_description = 'メッセージをただちに本番送信'
```
これは FCMのWebPush通知を送るAdminアクションのコードです。

アクション用の関数は、第3引数のqueryset にチェックボックスをつけたモデルのクエリセットが入ってきますので、それをループで回しながら何か処理を行い、結果は django.contrib.message で表示する、というのが常套手段です。

この関数を ModelAdmin に登録します。

```python3:admin/models.py
@admin.register(models.FcmMessage)
class DtbFcmMessageAdmin(admin.ModelAdmin):
    ...

    actions = (
        actions.send_fcm_message,
    )
```

### 削除アクションを消す
デフォルトでついている削除アクションを消す際は、私は Mixinを作って対応しています。

```python3
class NoDeleteAction:
    """
    ModelAdmin Mixin
    """

    def get_actions(self, request):
        actions = super().get_actions(request)
        if 'delete_selected' in actions:
            del actions['delete_selected']
        return actions
```
```python3:admin/models.py
@admin.register(models.MyModel)
class MyModelAdmin(NoDeleteAction, admin.ModelAdmin):
    ...

```

# 独自の Adminビューを作る
通常使うような FormView, CreateView, TemplateView などのジェネリックビューを使って作成できます。

作成したビューは admin_view デコレータを通して、ModelAdmin の get_url に組み込めば、安全な「Adminビュー」として機能します。

```python3:admin/views.py
from django.views.generic import TemplateView

from ..models import MyModel


class MyAppStatusView(TemplateView):
    template_name = "admin/myapp/status.html"

    def get_context_data(self, **kwargs):
        ...
```

```python3:admin/models.py
from . import views as admin_views

@admin.register(models.MyModel)
class MyModelAdmin(admin.ModelAdmin):

    def get_urls(self):
        extra_urls = [
            path('status/',
                self.admin_site.admin_view(
                    admin_views.MyAppStatusView.as_view()),
                name="status"),
        ]
        return extra_urls + super().get_urls()

```

# おわりに
Django Admin の簡単な概要としては以上です。このように、Django ではコア機能を応用してAdminページを作ることができ、必要箇所を書くだけで機能追加できるため少ないコードでやりたいことが実現でき、メンテナンス性もすぐれます。特に、DBのテーブルのリレーションを追っての検索や、Admin の Inline 機能は重宝しています。

今回書けなかったのですが、ログインユーザーに各アプリごとにAdmin内のパーミッションを細かく設定してアクセスコントロールしたり、操作ログを残したり、シグナルディスパッチャを使ってフックポイントに機能を差し込んだりといった機能が最初から用意されており、Battery Included の哲学に沿ったフルスタックなフレームワークといえます。