django-datatables-viewによるServer-side processingで、フォームの入力値に基づいてDataTableの絞り込みを行う

django-datatables-viewで、フォームの入力値に基づいてDataTableの絞り込みを行おうと考えた時に詰まったことがあったため、メモを残します。

 

目次

 

環境

なお、サンプルの見栄えを良くするため、Bootstrap 4.5.2 も使っています。

また、今回の実装ではDataTableのインスタンスをどこかに保持する必要がある & わかりやすくするために、JavaScriptclass を使っています*1

 

DataTableのSearchについて

DataTableには標準でSearch機能があります。

Searchはリアルタイムで行われます。例えば、

f:id:thinkAmi:20201016001229p:plain

という元データに対し、 Search欄に入力すると

f:id:thinkAmi:20201016001242p:plain

と絞り込みが行われます。

 

やりたいこと

状況によってはSearch欄以外にフォームを設けて、そこで絞り込みを行いたいこともあるかもしれません。

例えば、フォームに入力してFilterボタンを押すと、元データを

f:id:thinkAmi:20201016001450p:plain

と絞り込みたいとします。

 

実装

フロントエンド

Server Side Processingの作りとほぼ変わりありません。

ただ、filterボタンを押した時に絞り込みを行うため、

addEventHandler() {
  $('#filter').on('click', () => {
    // columnsの引数には、columnDefsで定義した列indexをセット
    this.instance.columns(1).search($('#filterName').val());
    this.instance.columns(3).search($('#filterIsBornInNagano').prop('checked'));
    this.instance.draw();
  })
}

と、DataTable APIcolumns.search() を使って、フォームの入力欄に対応した列でDataTableのSearchが動くようにしています。
https://datatables.net/reference/api/columns().search()

 
全体像はこちら

$(document).ready(function() {
  const app = new SearchApp();
  app.addEventHandler();
  app.loadDataTable();
});


class SearchApp {
  constructor() {
    // DataTableインスタンスを保持しておきたいため、クラスとして定義
    this.instance = null;
  }

  addEventHandler() {
    $('#filter').on('click', () => {
      // columnsの引数には、columnDefsで定義した列indexをセット
      this.instance.columns(1).search($('#filterName').val());
      this.instance.columns(3).search($('#filterIsBornInNagano').prop('checked'));
      this.instance.draw();
    })
  }

  loadDataTable() {
    this.instance = $('#demo').DataTable({
      autoWidth: false,
      serverSide: true,
      processing: true,
      responsive: true,
      ajax: {
        url: '/search-app/data/',
        type: 'GET',
      },
      columnDefs: [
        {targets: 0, data: 'id'},
        {targets: 1, data: 'name'},
        {targets: 2, data: 'color__name'},
        {
          targets: 3,
          data: 'born_in_nagano',
          render: data => {
            const checked = data ? 'checked="checked"' : '';
            return `<input type="checkbox" ${checked} />`;
          }
        },
      ]
    });
  }
}

 

バックエンド

もし、フォームで検索するのが文字列だけ (上記例では 品種名 だけ)であれば、django-datatables-view は一般的な使い方で検索できるようになります。

しかし、今回のモデルは

class Apple(models.Model):
    name = models.CharField('品種', max_length=50)
    color = models.ForeignKey('search_app.Color', on_delete=models.CASCADE)
    born_in_nagano = models.BooleanField('長野県生まれ')

と、 born_in_nagano 列がBooleanFieldとして定義されています。

 

BooleanFieldの何が問題かというと、 django-datatables-viewBaseDatatableView では、BooleanFieldが検索対象とならないことです。

というのも、 filter_queryset メソッドのコードを読むと、カラムごとの検索において

# column specific filter
if col['search.value']:
    qs = qs.filter(**{
        '{0}__{1}'.format(column, filter_method): col['search.value']})

となっています。

また、その中の変数 filter_method の値が FILTER_ISTARTSWITH = 'istartswith' と定義されています。

そのため、常に前方一致での検索になってしまいます。

 
そこで今回は、 filter_queryset メソッドをオーバーライドし、検索対象の列だけ特別な処理を追加します。

つまり、上記で示した部分を

# column specific filter
if col['search.value']:
    # BooleanField対応のため、if col_no を追加し、指定した列だけ処理を変える
    if col_no == 3:
        if col['search.value'] == 'true':
            qs = qs.filter(**{f'{column}': True})
    else:
        qs = qs.filter(**{
            '{0}__{1}'.format(column, filter_method): col['search.value']})

とします。

 
なお、今回のサンプルでは結果を分かりやすく確認するため、パフォーマンスのことを考えず、filter_methodは FILTER_ICONTAINS にしておきます。

Viewの全体像はこんな感じです。

class AppleSearchDataTableView(BaseDatatableView):
    model = Apple

    columns = [
        'id',
        'name',
        'color__name',
        'born_in_nagano',
    ]

    def render_column(self, row, col):
        if col == 'color__name':
            return row.color.name

        if col == 'born_in_nagano':
            # Booleanな項目の場合な場合、返し方によりJSでの型が異なるので注意
            # 暗黙的に返す -> JSではstring型
            # 明示的に返す -> JSではboolean型
            return row.born_in_nagano

        return super().render_column(row, col)

    def get_filter_method(self):
        """ 今回は結果をわかりやすくするために、部分一致(大文字小文字区別なし)にする
            デフォルトは、FILTER_ISTARTSWITH
        """
        return self.FILTER_ICONTAINS

    def filter_queryset(self, qs):
        """ Booleanフィールドの値はうまくSearchできない
            そのため、オーバーライドして一部を書き換える
        """
        columns = self._columns
        if not self.pre_camel_case_notation:
            # get global search value
            search = self._querydict.get('search[value]', None)
            q = Q()
            filter_method = self.get_filter_method()
            for col_no, col in enumerate(self.columns_data):
                # col['data'] - https://datatables.net/reference/option/columns.data
                data_field = col['data']
                try:
                    data_field = int(data_field)
                except ValueError:
                    pass
                if isinstance(data_field, int):
                    column = columns[data_field]  # by index so we need columns definition in self._columns
                else:
                    column = data_field
                column = column.replace('.', '__')
                # apply global search to all searchable columns
                if search and col['searchable']:
                    q |= Q(**{'{0}__{1}'.format(column, filter_method): search})

                # column specific filter
                if col['search.value']:
                    # BooleanField対応のため、if col_no を追加し、指定した列だけ処理を変える
                    if col_no == 3:
                        if col['search.value'] == 'true':
                            qs = qs.filter(**{f'{column}': True})
                    else:
                        qs = qs.filter(**{
                            '{0}__{1}'.format(column, filter_method): col['search.value']})
            qs = qs.filter(q)
        return qs

 

以上より、フォームの入力値に基づいてDataTableの絞り込みが行えるようになりました。

 

参考:Search機能との連動について

JavaScript側で columns.search() を使っているため、DataTableのSearch機能と追加したフォームを併用できます。

例えば、まずSearchで絞り込み、

f:id:thinkAmi:20201016003720p:plain

 

続いて、フォームで更に絞り込む、ということができます。

f:id:thinkAmi:20201016003743p:plain

 

ソースコード

GitHubに上げました。 search_app アプリが今回のアプリです。
https://github.com/thinkAmi-sandbox/django-datatables-view-sample

*1:インスタンスグローバル変数に保持すれば、classを使わなくて済みそうですが...