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を使わなくて済みそうですが...

django-datatables-viewによるServer-side processingで、モデルの複数列を結合して表示する

django-datatables-viewで、モデルの複数列を結合して表示しようと考えた時に詰まったことがあったため、メモを残します。

 
目次

 

環境

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

 

やりたいこと

以下のような2つのモデルがあったとします。

class Color(models.Model):
    name = models.CharField('色', max_length=10)


class Apple(models.Model):
    name = models.CharField('品種', max_length=50)
    color = models.ForeignKey('concat_col_app.Color', on_delete=models.CASCADE)
    breeding = models.CharField('交配', max_length=100)

 
DataTableの表示では、 colorname の値を組み合わせ、以下のように タイトル 列として表示したいとします。

f:id:thinkAmi:20201011105435p:plain

 

エラーとなる方法

画面の表示に関するものなので、Django側は render_column を使い

class AppleConcatDataTableView(BaseDatatableView):
    model = Apple

    columns = [
        'id',
        'title',  # 組み合わせて表示したい列を追加
        'breeding',
    ]

    def render_column(self, row, col):
        # title列が入ってきたら、colorとnameを組み合わせて表示
        if col == 'title':
            return f'[{row.color.name}] {row.name}'

        return super().render_column(row, col)

としてみました。

また、JavaScript側も、 title 列を受け取れるようにします。

$('#demo').DataTable({
  autoWidth: false,
  serverSide: true,
  processing: true,
  responsive: true,
  ajax: {
    url: '/concat-col-app/data/',
    type: 'GET',
  },
  columnDefs: [
    {targets: 0, data: 'id'},
    {targets: 1, data: 'title'},
    {targets: 2, data: 'breeding'},
  ]
});

 
しかし、初期表示はうまくいくものの、Searchやタイトル列をクリックするとエラーが表示されてしまいます。

f:id:thinkAmi:20201011110126p:plain

 
また、Djangoのログにも以下が記録されています。

...
    raise FieldError("Cannot resolve keyword '%s' into field. "
django.core.exceptions.FieldError: Cannot resolve keyword 'title' into field. Choices are: breeding, color, color_id, id, name

 

動作する方法

Djangoのログを見ると、Djangoのmodelに title が見当たらないのが原因のようです。

READMEにある Another example of views.py customisation のうち、 get_initial_queryset メソッドの説明を読むと

def get_initial_queryset(self):
    # return queryset used as base for futher sorting/filtering
    # these are simply objects displayed in datatable
    # You should not filter data returned here by any filter values entered by user. This is because
    # we need some base queryset to count total number of records.
    return MyModel.objects.filter(something=self.kwargs['something'])

https://bitbucket.org/pigletto/django-datatables-view/src/master/

と書かれています。

そのため、django-datatables-viewの get_initial_queryset メソッドをオーバーライドしてQuerySetに title 属性を追加することで、filterやsortにも対応できそうです。

 
そこで、Django側のViewを修正します。

今回はレコードごとに列を追加するため、Djangoの annotate句と

を使うこと、出力したい [色] 品種名 という形式の値を持つ title 属性を追加します。

from django.db.models import Value
from django.db.models.functions import Concat
from django_datatables_view.base_datatable_view import BaseDatatableView

from concat_col_app.models import Apple


class AppleConcatDataTableView(BaseDatatableView):
    model = Apple

    columns = [
        'id',
        'title',
        'breeding',
    ]

    def get_initial_queryset(self):
        # title列を追加
        return super().get_initial_queryset().annotate(
            title=Concat(Value('['), 'color__name', Value('] '), 'name')
        )

 
なお、表示内容については、get_initial_querysetで作成した title 列の値そのもので良いため、今回は render_column のオーバーライドは不要です。

 
これにより、Searchやタイトル列クリックによるソートを行っても動作するようになりました。

以下の画像でも、タイトル列の記号にある通り、タイトル列でのソートができていることが分かります。

f:id:thinkAmi:20201011111402p:plain

 

ソースコード

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

django-datatables-viewによるServer-side processingで、色々なソートを試してみた

ライブラリ django-datatables-view を使ってjQuery DataTableのServer Side Processing を行った際、ソートを実装することがありました。

ただ、ソートを実装しようとしたところ詰まったことがあったため、メモを残します。

 

目次

 

環境

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

 

ソートを意識しない時の実装

このようなモデルがあったとします。

class Color(models.Model):
    name = models.CharField('色', max_length=10)


class Apple(models.Model):
    name = models.CharField('品種', max_length=50)
    color = models.ForeignKey('sort_app.Color', on_delete=models.CASCADE)
    breeding = models.CharField('交配', max_length=100)
    season = models.IntegerField('旬')
    born_in_nagano = models.BooleanField('長野県生まれ')

 

このAppleモデルをDataTableに表示します。

その時の要件として、

  • season (旬) が実行月と一致した場合は、チェックボックスにチェックを入れる
  • 最初は、id列でソート

があった時に、以下のような表示をしたいとします。

f:id:thinkAmi:20201010212004p:plain

 

この時の django-datatables-view を使ったViewの実装はこんな感じです。

class AppleAllFieldDataTableView(BaseDatatableView):
    model = Apple

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

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

        if col == 'season':
            if row.season == timezone.now().month:
                return True
            return False

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

        return super().render_column(row, col)

注意点として、Djangoのモデルで BooleanField は、そのままJavaScript側に渡すと string 型になります。

それでは扱いづらいので、 render_column() メソッドをオーバーライドし、明示的にPythonのTrue/Falseを返すようにします。これにより、JavaScriptには boolean 型の値が渡されます。

 
続いて、JavaScript側の実装です。

DataTableの中でチェックボックスを表示できるよう、render 属性を使って描画するHTMLを指定します。

$('#all-columns').DataTable({
  autoWidth: false,
  serverSide: true,
  processing: true,
  responsive: true,
  pageLength: 25,  // サンプルデータ量が多いため、デフォルト値を変えておく
  ajax: {
    'url': '/sort-app/all-columns/data/',
    'type': 'GET',
  },
  columnDefs: [
    {targets: 0, data: 'id'},
    {targets: 1, data: 'name'},
    {targets: 2, data: 'color__name'},
    {targets: 3, data: 'breeding'},
    {
      targets: 4,
      data: 'season',
      render: data => {
        const checked = data ? 'checked="checked"' : '';
        return `<input type="checkbox" ${checked} />`;
      }
    },
    {
      targets: 5,
      data: 'born_in_nagano',
      render: data => {
        const checked = data ? 'checked="checked"' : '';
        return `<input type="checkbox" ${checked} />`;
      }
    },
  ],
});

 
なお、JavaScript側の render()Django側の render_column() は、あくまで見た目を整えるだけであり、実際のデータとは異なります。

そのため、 列でソートしたとしても、DB上のデータは xx月であることから、チェックボックスがONの順番ではソートされません。

f:id:thinkAmi:20201010214057p:plain

 
これをベースに、いろいろ試していきます。  
 

複合ソートキーでの結果を初期表示する場合

JavaScript側で対応が必要になります。

jQuery DataTableでは、初期表示するソートキーの列は order 属性を使います。
DataTables example - Default ordering (sorting)

[ソートキー列のindex, ソート順文字列] という要素を用意し、それをArrayで指定します。ソート順文字列は ascdesc のどちらかを指定します。

$('#all-columns').DataTable({
  serverSide: true,
  processing: true,
  // ...
  columnDefs: [ /* 略 */ ]
  order: [
    [3, 'asc'],
    [5, 'desc'],
    // ソートキーが重複した場合にもソートが常に一定となるよう、idをソートキーに加えておく
    [0, 'asc'],
  ],
});

 
これにより、

  1. 交配
  2. 長野県生まれ
  3. ID

の複合キーで、指定した順(昇順/降順)にて、ソートされるようになりました。

f:id:thinkAmi:20201010212933p:plain

 

ソート不可列がある時のソートについて

JavaScript側、Django側の双方で対応が必要になります。

JavaScript側の修正

DataTableでソート不可列を指定するには、columnDefsの中で該当の列に orderable: false を指定します。

また、ソート不可列を order 属性に含めないよう、注意します。

$('#some-columns').DataTable({
  // ...
  columnDefs: [
    {targets: 0, data: 'id'},
    // ...
    {
      targets: 5,
      data: 'born_in_nagano',
      searchable: false,
      orderable: false,  // ソート不可
      render: data => {
        const checked = data ? 'checked="checked"' : '';
        return `<input type="checkbox" ${checked} />`;
      }
    },
  ],
  order: [
    // ソート不可な列を追加するとエラーになるので注意
    [3, 'asc'],
    [0, 'asc'],
  ]
});

 

Django側の修正

django-datatables-viewのREADMEにあるサンプルを読むと、

# https://bitbucket.org/pigletto/django-datatables-view/src/master/

# define column names that will be used in sorting
# order is important and should be same as order of columns
# displayed by datatables. For non sortable columns use empty
# value like ''
order_columns = ['number', 'user', 'state', '', '']

との記載があります。

そのため、 order_columns を追加で定義し、ソート不可項目のIndexの要素には、空文字を指定します。

class AppleSomeFieldDataTableView(BaseDatatableView):
    model = Apple

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

    order_columns = [
        'id',
        'name',
        'color__name',
        'breeding',
        'season',
        '',  # born_in_nagano部分はソート不可にする
    ]

    def render_column(self, row, col):
        ...

 

結果

f:id:thinkAmi:20201010214131p:plain

 

権限により、列の表示可否が変わる時のソートについて

例えば、「ログインしている場合のみ、 交配 列を表示する」という仕様が追加になったとします。

この場合、

  • ログイン状態により、DataTableの列表示可否を変更
  • ソート機能を変更

の2つの対応が必要になります。

 
そのため、順番に作業を行います。

 

ログイン状態により、DataTableの列表示可否を変更
テンプレートの変更

DataTableのヘッダの表示を動的に変更します。

<table id="perms-columns" class="table table-striped table-bordered dataTable" style="width:100%">
  <thead>
  <tr>
    <th>ID</th>
    <th>品種</th>
    <th></th>

    {# 交配は、ログインしていれば見える #}
    {% if request.user.is_authenticated %}
      <th>交配</th>
    {% endif %}

    <th></th>
    <th>長野県生まれ</th>
  </tr>
  </thead>
</table>

 

JavaScript側の変更

columnDefsを動的に制御するため、以下の関数を追加します。

function getColumnDefs() {
  const results = [];
  let colIndex = 0;
  results.push({targets: colIndex++, data: 'id'});
  results.push({targets: colIndex++, data: 'name'});
  results.push({targets: colIndex++, data: 'color__name'});

  if ($('#username').length) {
    results.push({targets: colIndex++, data: 'breeding'});
  }
  results.push({
    targets: colIndex++, data: 'season',
    render: data => {
      const checked = data ? 'checked="checked"' : '';
      return `<input type="checkbox" ${checked} />`;
    }
  });
  results.push({
    targets: colIndex++, data: 'born_in_nagano',
    render: data =>{
      const checked = data ? 'checked="checked"' : '';
      return `<input type="checkbox" ${checked} />`;
    }
  });

  return results;
}

 
そして、この関数の戻り値を、columnDefsに指定します。

なお、 columnDefs: () => { /* .. */ } のような定義では動作しませんでした。

columnDefs: getColumnDefs(),

 

Django側の修正

get_columns メソッドをオーバーライドし、ログインしている場合のみ 交配 列を追加します。

class ApplePermsFieldDataTableView(BaseDatatableView):
    model = Apple

    def get_columns(self):
        results = [
            'id',
            'name',
            'color__name',
        ]

        if self.request.user.is_authenticated:
            results.append('breeding')

        results.extend([
            'season',
            'born_in_nagano',
        ])

        return results

 

ソート機能を変更

列表示が動的になる場合、JavaScriptDjango側で表示する列数を合わせる必要があります。具体的には以下の4項目です。

 
ここまでで対応していないのはJavaScript側の order ですので、対応を行います。

まずは columnDefs同様、列を動的に変更する関数を用意します。

function getOrder() {
  const results = [];
  let bornIndex = 4;  // 非表示時の "born_in_nagano" 位置

  if ($('#username').length) {
    results.push([3, 'asc']);
    bornIndex++;  // 列が増えるため、"born_in_nagano" 位置を修正
  }
  results.push([bornIndex, 'desc']);

  // ソートキーが重複した場合にもソートが常に一定となるよう、idをソートキーに加えておく
  results.push([0, 'asc']);

  return results;
}

 

続いて、 order属性にこの関数の戻り値を割り当てます。

$('#perms-columns').DataTable({
  // ...
  columnDefs: getColumnDefs(),

  // 変更
  order: getOrder(),
});

 

結果

以上の対応により、動的に列数が変わったとしても、初期ソート状態やソート機能が利用できるようになります。

 

未ログイン時

交配 列が表示されず、 長野県生まれ 列の降順だけでのソートとなります。

f:id:thinkAmi:20201010215509p:plain

 

ログイン済時

交配 列の昇順・ 長野県生まれ 列の降順でソートされています。

f:id:thinkAmi:20201010215716p:plain

 
 

ソースコード

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

django-datatables-viewによるServer-side processingで、モデルの外部キーの項目を表示する

バックエンドがDjangoの環境にて、jQuery DataTable を使う機会がありました。
DataTables | Table plug-in for jQuery

データ量がそれなりにあったので、DataTableのServer Side Processingを使いました。
DataTables example - Server-side processing

DjangoでServer Side Processingする場合は、ライブラリ django-datatables-view を使えば良さそうでした*1

 
ただ、 django-datatables-view にて、モデルの外部キーの項目を表示する時に少々詰まったので、メモを残します。

 

目次

 

環境

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

 

やりたいこと

以下の通り、3つのモデルがあるとします。

class Family(models.Model):
    """ 科 """
    name = models.CharField('名前', max_length=50)


class Species(models.Model):
    """ 種 """
    family = models.ForeignKey('fk_app.Family', on_delete=models.CASCADE)
    name = models.CharField('名前', max_length=50)


class Cultivars(models.Model):
    """ 品種 """
    species = models.ForeignKey('fk_app.Species', on_delete=models.CASCADE)
    name = models.CharField('名前', max_length=50)

 
これらのモデルのうち、 Cultivarsdjango-datatables-viewmodel に指定し、こんな感じで表示したいとします。

注意点としては

  • は、品種から見て外部キーの外部キー
  • は、品種から見て外部キー

です。

f:id:thinkAmi:20201008193925p:plain

 

対応

通常の項目である idname

class CultivarsDataTableView(BaseDatatableView):
    model = Cultivars

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

と、 column に列名をそのまま指定できます。

一方、外部キーの項目については、そのまま species を指定しても、うまく表示できません。

f:id:thinkAmi:20201008204259p:plain:w400

 

そのため、Django側・JavaScript側の両方で対応が必要になります。

 

Django
render_column() のオーバーライド

表示ができていないので、render_column()をオーバーライドし、外部キー列が指定された場合は、その name を表示するようにします。

class CultivarsDataTableView(BaseDatatableView):
    model = Cultivars

    columns = [
        'id',
        'family',
        'species',
        'name',
    ]

    def render_column(self, row, col):
        # 列 familyが来たら、外部キーの外部キーのnameを返す 
        if col == 'family':
            return row.species.family.name

        if col == 'species':
            return row.species.name

 
CultivarsDataTableViewの columns に合わせ、JavaScriptcolumnDefs を定義します。

columnDefs: [
  {targets: 0, data: 'id'},
  {targets: 1, data: 'family'},
  {targets: 2, data: 'species'},
  {targets: 3, data: 'name'},
]

 
また、DataTableを表示するHTMLテンプレートのtableタグは

    <table id="demo" class="table table-striped table-bordered dataTable" style="width:100%">
      <thead>
      <tr>
        <th>ID</th>
        <th></th>
        <th></th>
        <th>品種</th>
      </tr>
      </thead>
    </table>

とします。

 
この場合、画面はうまく表示されます。

しかし、外部キーの外部キーの列 (ここでは ) でソートしようとすると、エラーとなります。

f:id:thinkAmi:20201008213420p:plain:w400

 
Djangoの実行ログにも以下が記録されています。

raise FieldError("Cannot resolve keyword '%s' into field. "
django.core.exceptions.FieldError: Cannot resolve keyword 'family' into field. Choices are: id, name, species, species_id

 

columnsの定義変更

エラーで落ちている ordering メソッドのソースコードを見ると、columnsなどで定義した列名をDjangoのQuerySetのorder_byメソッドに渡しているようです。

そのため、order_byで解釈できる列として定義してあげれば良さそうでした。

そこで、Django側を

class CultivarsDataTableView(BaseDatatableView):
    model = Cultivars

    columns = [
        'id',
        # order_byで解釈できるように定義
        'species__family__name',
        'species__name',
        'name',
    ]

    def render_column(self, row, col):
        # 列名が変わったので、render_columnメソッドのif文も変更
        if col == 'species__family__name':
            return row.species.family.name

        if col == 'species__name':
            return row.species.name

        return super().render_column(row, col)

とします。

また、columnsの定義が変わったので、JavaScript側の columnDefs

columnDefs: [
  {targets: 0, data: 'id'},
  {targets: 1, data: 'species__family__name'},
  {targets: 2, data: 'species__name'},
  {targets: 3, data: 'name'},
]

のように変更します。

 
その結果、 列をクリックしてもエラーが起きなくなりました。

 

まとめ
  • Django
    • 外部キーの属性を表示したい場合は、columns__ で結合して定義する
    • 上記の名前で描画できるよう、 render_column() メソッドをオーバーライドし、外部キーの該当の値を指定する
  • JavaScript
    • columnDefsの dataキーに、 Djangocolumns で定義した名前を指定する

 

ソースコード

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

*1:ライブラリを使わずに実装したことがある同僚の @qtatsu いわく、これはとても便利とのことです

書籍「Rustで始めるネットワークプログラミング」を写経した

日頃Webアプリケーションを作成しているのですが、もう少し低レイヤのことを知りたくなりました。

コードを書きながら進めたほうが理解が早いだろうと思ったので、何か良い本がないかを調べたところ、書籍「Rustで始めるネットワークプログラミング」がありました。

 
著者の方のBlogには目次や対象読者などが詳しく書かれており、自分が知りたい低レイヤ方面だったため、ちょうど良さそうでした。
「Rustで始めるネットワークプログラミング」を出版しました。 - teru_0x01.log

また、Rustもさわってみたかったので、この本を写経することにしました*1

 

内容は、Blogでもふれられている通り、Rustの文法についてはほぼ記載がありませんでした。

とはいえ、この本に期待していたのは「Rustでネットワークプログラミングする時の書き方やライブラリを知ること」だったので、特に問題ありませんでした。

文法は手元に

プログラミング言語Rust入門

プログラミング言語Rust入門

などのRust本を置いておき、必要に応じて参照するようにしました。

 

一方、期待していた「Rustでネットワークプログラミングする時の書き方やライブラリを知ること」には満足しました。

ソケット通信から始まって徐々に題材が深くなり、Webサーバを作り、最後にはRFCを読み下しながらDHCPサーバを作るなど、題材も豊富でした。

 

Rustの書き方についても、例えば、個人で進めると悩むような ? 演算子の使用例があり、こんなふうに使うんだと体感しました。
Rust のエラーハンドリングはシンタックスシュガーが豊富で完全に初見殺しなので自信を持って使えるように整理してみたら完全に理解した - Qiita

他にも

なども普通に使われていました。

言語をさわり始める時に、言語仕様をまとめたドキュメントを読むよりも、何かを作っていくほうが言語を理解しやすい自分にとっては、とても合っていた書籍でした。

とはいえ、まだまだ理解したとはいいがたいので、写経の内容を振り返りつつ、色々と手を動かしていこうと思ったのでした。
https://github.com/thinkAmi-sandbox/syakyo-starting_network_programming_with_rust

 
良い本をありがとうございました。

*1:実際に写経したのは8月頃ですが、記事にできてなかったので忘れないうちに書いておきます

Rust + PyO3にて、自作のPythonモジュールをRustで実行する

RustからPythonのモジュールを使うときは、 PyO3 が便利です。
PyO3/pyo3: Rust bindings for the Python interpreter

 
ただ、公式ドキュメントでは、Pythonの標準モジュールをimportして実行する方法は記載されていたものの、自作のPythonモジュールについては記載が見当たりませんでした。
Calling Python from Rust - PyO3 user guide

 
そこで、調べたことをメモしておきます。

 
目次

 

環境

 

自作のPythonモジュール

以下の内容を hello.py として用意します。この中の say() 関数をRustで使いたいとします。

def say(message):
    return f'hello, {message}'

 

ディレクトリ構造
$ tree
.
├── Cargo.lock
├── Cargo.toml
├── hello.py
├── src
│   └── main.rs
└── target

 

ダメな方法

標準モジュールのように

let gil = Python::acquire_gil();
let py = gil.python();
let hello = py.import("hello")?;

とimportした場合、コンパイルは通るものの、実行すると以下のエラーになります。

Error: PyErr { type: Py(0x10529f7a8, PhantomData) }

 

対応:sys.pathにエントリを追加する

以下のissueにある通り、自作モジュールがsys.pathに追加されていないため、PyO3を使って追加します。
How can I import Python code into Rust? · Issue #148 · PyO3/pyo3

今回は、カレントディレクトリにPythonファイルを置いたため、カレントディレクトリを sys.path に追加します。

let syspath: &PyList = py.import("sys")
    .unwrap()
    .get("path")
    .unwrap()
    .try_into()
    .unwrap();

let path = env::current_dir()?;

// path.display()をそのまま渡すとエラーになるので、文字列にして渡す
// error[E0277]: the trait bound `std::path::Display<'_>: pyo3::conversion::ToPyObject` is not satisfied
// syspath.insert(0, path.display()).unwrap();
syspath.insert(0, format!("{}", path.display())).unwrap();

 
これで import できるようになりました。

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

use pyo3::prelude::*;
use pyo3::types::PyList;
use std::env;


fn main() -> PyResult<()> {
    let gil = Python::acquire_gil();
    let py = gil.python();

    let syspath: &PyList = py.import("sys")
        .unwrap()
        .get("path")
        .unwrap()
        .try_into()
        .unwrap();

    let path = env::current_dir()?;

    // error[E0277]: the trait bound `std::path::Display<'_>: pyo3::conversion::ToPyObject` is not satisfied
    // syspath.insert(0, path.display()).unwrap();
    syspath.insert(0, format!("{}", path.display())).unwrap();
    
    let hello = py.import("hello")?;
    let response: String = hello.call1("say", ("Shinano Gold", ))?.extract()?;
    println!("{}", response);

    Ok(())
}

 
cargo run すると、say()関数の結果が表示されました。

$ cargo run
   Compiling using_python_from_rust_by_pyo3 v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 2.83s
     Running `target/debug/using_python_from_rust_by_pyo3`
hello, Shinano Gold

 
なお、READMEには

Python::with_gil(|py| { /**/ }

のような書き方がされていますが、これは次のバージョン (0.12) よりサポートされるようです。
no function or associated item named with_gil found for struct pyo3::python::Python<'_> · Issue #1079 · PyO3/pyo3

そのため、今のバージョンで書こうとすると、 with_gil のところでコンパイルエラーになります。

 

その他

RustでPythonを扱う方法としては、PyO3の他に rust-cpython もあります。
dgrunwald/rust-cpython: Rust <-> Python bindings

ただ、GitHubのstar数や、rust-numpyがバージョン0.3以降rust-pythonからPyO3に移ったことを考えて、今回はPyO3を使いました。
PyO3/rust-numpy: PyO3-based Rust binding of NumPy C-API

PyO3については、メンテナの方の記事が詳しいです。
PyO3: これまで/これから - Qiita

Django REST Framework + jQuery + S3で画像ファイルアップローダーを作った時のメモ

Django REST Framework + jQuery + S3で画像ファイルアップローダーを作る機会がありました。

その中で色々と考えたことをメモに残します。

なお、実装の詳細は以下となります。
thinkAmi-sandbox/image_uploader_by_drf_jquery_s3

 
目次

 

環境

 

仕様など

画面イメージ

f:id:thinkAmi:20200730221937p:plain:w200

 

仕様
  • 作るものはメモアプリ
  • タイトル・内容・複数画像を指定して保存
    • 画像指定後、保存ボタンを押すことでストレージへ保存する
    • 複数画像を選択するボタンはアイコンだけにする
  • 保存後、再度開いたときは最新のメモを表示
    • メモ一覧があるとより良いが、今回のサンプルでは省略
    • 複数画像のうちの一部を削除可能
      • 保存前・保存後のどちらも削除可能
  • 画像や静的ファイルはAWS S3へと保存
  • フロントエンドはjQuery、バックエンドはDjango REST Framework (以降、DRFと表記)
    • jQuery.ajax() で通信
  • ファイルはDjangoのImageFieldを使って保存

 

Web APIでファイルアップロードする方法について

Web APIでファイルをアップロードする方法について、以下の記事を参考に考えてみました。

 

送信する時のContent-Typeについて

記事には

  • multipart/form-dataで送信
  • Base64化して、application/jsonで送信

の2つがありました。

 
DRFではどちらの方が都合が良いかを見たところ、DRFではデフォルトでどちらの形式でも受け取れるため、どちらでも良さそうでした。
Django REST FrameworkのDEFAULT_PARSER_CLASSESの初期値について - メモ的な思考的な

 
次に仕様を考えると、今回はJSON縛りはありませんでした。

そこで、Base64化する必要のない multipart/form-data を採用しました。

 

ファイル単体で送信するか、データとくっつけて送信するか

記事ではファイルの保存方法として

  • リソース紐付けパターン
  • 汎用ファイルアップロードパターン

が挙げられていました。

 
今回の仕様 ファイルはDjangoのImageFieldを使って保存 に加え、DjangoのImageFiledではPillowを使ったバリデーションも含まれていることから、前者のリソース紐付けパターンを採用しました。

 
なお、今回は1つのメモで複数の画像を保存できるようにすることから、モデルを2つ用意しました。

まずは親となる Memo モデルです。

class Memo(models.Model):
    title = models.CharField('タイトル', max_length=30)
    content = models.TextField('内容', default='')

続いて、画像を保存するための Picture モデルです。

class Picture(models.Model):
    memo = models.ForeignKey('api.Memo',
                             on_delete=models.CASCADE,
                             related_name='pictures')
    file = models.ImageField()
    name = models.CharField('ファイル名', max_length=250, default='example.png')

 

複数ファイルの選択について

複数ファイルの選択を可能にする方法として、

  • input type="file"multiple を使う
  • ファイルを選択するごとに、 input type="file" を複数動的に生成する

のどちらかがありました。

今回は特に制限はなかったため、実装が容易な multiple を使いました。

 

ファイルのアップロードボタンをなくしてアイコンだけにする

通常、 input type="file" なタグの場合、以下のようにボタンと選択したファイルが表示されます。

f:id:thinkAmi:20200727222655p:plain:w300

 

ただ、今回は 複数画像を選択するボタンはアイコンだけにする という仕様でした。

つまり、こんな感じのアイコンをクリックすることで、ファイルを開くダイアログを表示するようにします。

f:id:thinkAmi:20200727222709p:plain:w100

(今回のメモアプリでは、Googleのマテリアルアイコン add_photo_alternate を借用:https://material.io/resources/icons/?icon=add_photo_alternate&style=round)

 
この場合、labelで囲んでinputは display:none にします。

 

画像のプレビューについて

選択した画像のプレビューについて

FileReaderを使ってプレビューを表示させます。

 

保存済の画像のプレビューについて

保存済の画像については、DRF側で保持している画像データを元に表示するため、 input type="file" が使えません。

そこで、モデルのImageFieldで保存されている画像パスを使ってプレビューを作ります。

まずはシリアライザでImageFiledをJS側へと渡せるようにします。

class PictureSerializer(serializers.ModelSerializer):
    class Meta:
        model = Picture
        fields = '__all__'


class MemoSerializer(serializers.ModelSerializer):
    pictures = PictureSerializer(many=True, read_only=True)
    class Meta:
        model = Memo
        fields = '__all__'

 
あとは、JS側で受け取った画像のURLをimgタグのsrcへ設定します。

 

選択した画像のうち、保存不要な画像を削除する方法について

今回は画像の隣りにある削除ボタンにて、プレビューしたら不要になった画像ファイルを削除できるようにします。

選択したファイルのうち不要なファイルを削除するには

  • HTML上からimgタグを削除
  • 削除した画像を送信しないよう、ajax()での送信時に不要なファイルを送信しないようにする

の2つを行います。

今回、 input type="file" には multiple 属性を付与しています。

しかし、multipleで選択したファイルは FileList として保存されます。ただし、ReadOnlyなため、個別には削除できません。
FileList - Web API | MDN

 
何か方法がないかを調べたところ、以下の情報がありました。

 

今回は input type="file" には multiple 属性を使っているため、

  • 削除ボタンをクリックした時に、不要なファイルのindexを取得
  • 保存ボタンをクリックした時に、保存が必要な画像ファイルだけFormDataオブジェクトに追加

としました。

こんな感じでFormDataオブジェクトに追加します。

送信するかどうかを判定するため、ファイル以外の項目も一つずつ追加しています。

const formData = new FormData();

// ファイル以外の項目を手動でセット
formData.append('id', $('#memo-id').val());
formData.append('title', $('#title').val());
formData.append('content', $('#content').val());

// 送信が必要なファイルのみFormDataへ追加
const files = $('#pictures').get(0).files;
for (let i = 0; i < files.length; i++) {
  if (!this.excludePictureIndexes.includes(i)) {
    formData.append('additional_pictures', files[i]);
  }
}

 

動的にボタンを含むHTMLを生成した時のクリックイベント割り当て(jQuery)

最初はこんな感じのHTMLだけがあります。

<div id="additions"></div>

 
ファイルを選択することで、選択したファイル分、上記のdivタグの中に

<div class="preview">
  <p>
    <img src="${src}" alt="${alt}" style="width: 20%; max-width:300px;">
    <button type="button" class="remove-picture"
            data-additional-picture-index="${additionalPictureIndex}"
            data-registered-picture-id="${registeredPictureId}">
      <i class="material-icons">delete</i>
    </button>
  </p>
</div>

というHTMLが動的に追加されます。

 
このとき、追加の都度イベントハンドラを追加するのは手間でした。

そのため、以下のようにすることで、イベントハンドラの追加は1回だけになりました。

$('#additions').on('click', '.remove-picture', (event) => {
  this.removePicture(event);
})

 

同じ画像ファイルを選択できるようにする

onChangeイベントだけでは、

  1. ファイルをプレビュー表示
  2. プレビュー表示しているファイルを削除
  3. 同じファイルをプレビュー表示

とすると、3.の段階ではプレビューが表示できません。

そこで、onClickイベントとonChangeイベントを使うことで、再度同じファイルを選択できるようにします。
jquery - How to detect input type=file "change" for the same file? - Stack Overflow

const fileTag = $('#pictures');
fileTag.on('click', function() {
  $(this).prop('value', '');
})

fileTag.on('change', (event) => {
  this.createAdditionalPicturePreviews(event);
});

 

静的ファイル・ユーザーがアップロードしたファイルを、ともにS3に置く

django-storages を使います。
jschneier/django-storages: https://django-storages.readthedocs.io/

書籍「現場で使えるDjangoの教科書 実践編」に詳細があるため、そちらに従って実装します。

 

テストコードで、S3にファイルをアップロードしないようにする方法について

django-storages を使っている環境でfacotry_boyで ImageFIeld のフェイクデータを作成した場合、テストコードを走らせたときにS3へファイルが置かれてしまいます。

また、テストが終わっても自動で画像ファイルを削除してくれません。

# Factory
class PictureFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Picture

    memo = factory.SubFactory(MemoFactory)
    name = factory.Faker('word')
    file = factory.django.ImageField(color='blue')


# テスト
def test_成功_GET_差し替え無し(self):
    PictureFactory()  # ここでS3にファイルが置かれる
    url = reverse('api:memo-list')
    actual = APIClient().get(url)

    assert actual.status_code == HTTPStatus.OK

 

そこで、テストコードを走らせるときはS3にアップロードしないようにするには、以下の方法があります。

  • インメモリなストレージを使う
  • S3をモックしてくれるmotoを使う

 

インメモリなストレージを使う

dj-inmemorystorage を使うことで、インメモリなストレージに切り替えられます。 zsiciarz/dj-inmemorystorage: A non-persistent in-memory data storage backend for Django.

なお、以下の例では @override_settings デコレータを使って差し替えていますが、テスト用のsettings.pyを用意したほうが良いかもしれません。

@override_settings(DEFAULT_FILE_STORAGE='inmemorystorage.InMemoryStorage')
def test_成功_GET_インメモリなストレージに差し替え(self):
    PictureFactory()  # この時点でフェイクファイルが作られる
    url = reverse('api:memo-list')
    actual = APIClient().get(url)

    assert actual.status_code == HTTPStatus.OK

 

motoを使う

もう一つの方法として、AWSリソースをモックしてくれる moto があります。
spulec/moto: A library that allows you to easily mock out tests based on AWS infrastructure.

こちらの場合は、 @mock_s3 デコレータを使うことで、S3をモックしてくれます。

注意点としては、mock_s3だけではバケットを作ってくれないので、バケットを作らないとエラーになります。

botocore.errorfactory.NoSuchBucket:
An error occurred (NoSuchBucket) when calling the PutObject operation:
The specified bucket does not exist

 
そのため、バケットを作った後に、実際にテストしたい内容を書きます。

@mock_s3
def test_成功_GET_motoに差し替える(self):
    s3_client = boto3.client('s3')
    s3_client.create_bucket(Bucket=settings.MEDIA_AWS_STORAGE_BUCKET_NAME)

    PictureFactory()
    url = reverse('api:memo-list')
    actual = APIClient().get(url)

    assert actual.status_code == HTTPStatus.OK

 

ソースコード (再掲)

GitHubに上げました。
thinkAmi-sandbox/image_uploader_by_drf_jquery_s3