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

GitLabのプライベートリポジトリから複数人で使うサーバ上へgit pullする時に、deploy tokenを使ってみた

GitLabのプライベートリポジトリから複数人で使うサーバ上へgit pullする機会がありましたが、ちょっと悩んだためメモを残します。

 
目次

 

環境

  • GitLab.comのプライベートリポジトリ
  • GitLabアカウントには2FA設定済

 

困ったこと

そのサーバのgit remoteには、GItLabのプライベートリポジトリにhttpsでアクセスするoriginが設定してありました。

ただ、自分のアカウントでgit pullしようとしても

という状況であり、困っていました。

 

対応

GitLabのドキュメントを読んだところ、 Deploy Token がありました。
Deploy Tokens | GitLab

 
ドキュメントを読むと、リポジトリ単位でScopeを決めてトークンを発行できるようです。

今回、サーバ上で操作する全員がGitLab上のプライベートリポジトリへのアクセス権を持っていたため、サーバ上にDeploy Tokenを書いてあっても問題ありませんでした。

また、deploy token用にgit remoteの差し替えが必要そうでしたが、こちらも特に問題なさそうでした。

そこで、deploy tokenを試してみることにしました。

 

まずはドキュメントに従い、GitLabのプライベートリポジトリに、deploy tokenを設定します。

今回はcloneできれば良いので、Scopeは read_repository にしました。

 
続いて、サーバ上でgit remoteを差し替えます。

# git remoteの差し替え
$ git remote set-url origin https://<deploy token用ユーザー>:<deploy token>@gitlab.com/<グループ(ユーザー)>/<リポジトリ>.git

# 確認
$ git remote -v
origin  https://<deploy token用ユーザー>:<deploy token>@gitlab.com/<グループ(ユーザー)>/<リポジトリ>.git (fetch)
origin  https://<deploy token用ユーザー>:<deploy token>@gitlab.com/<グループ(ユーザー)>/<リポジトリ>.git (push)

 
最後にgit pullしたところ、問題なく動作しました。

$ git pull
remote: Enumerating objects: 12, done.
remote: Counting objects: 100% (12/12), done.
remote: Compressing objects: 100% (12/12), done.
remote: Total 12 (delta 4), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (12/12), done.
From https://gitlab.com/<グループ(ユーザー)>/<リポジトリ>
 * [new branch]      yy-zzz     -> origin/<ブランチ名>
   xxxxxxx..xxxxxxx  master     -> origin/master

Django REST FrameworkのDEFAULT_PARSER_CLASSESの初期値について

Django REST Frameworkでは、 DEFAULT_PARSER_CLASSES の設定により、グローバルで使われるParserが決まります。
Parsers - Django REST framework

ただ、その初期値をうっかり忘れていたので、メモを残します。

 
目次

 

環境

 

うっかりしていたこと

Django REST Frameworkの DEFAULT_PARSER_CLASSES ですが、デフォルトでは以下のとおりです。

# https://github.com/encode/django-rest-framework/blob/3.11.0/rest_framework/settings.py#L33

'DEFAULT_PARSER_CLASSES': [
    'rest_framework.parsers.JSONParser',
    'rest_framework.parsers.FormParser',
    'rest_framework.parsers.MultiPartParser'
],

 
そのため、デフォルトでは、JSONの他に普通のHTMLフォームからの送信(application/x-www-form-urlencoded)も受け付けることをうっかり忘れていました。

 

動作確認

アプリ

普通のHTMLフォームからの送信も受け付けることを確認します。

以下のようなDjangoDjango REST Frameworkを使ったHTMLフォームを用意します。

 

API

モデル

from django.db import models


class Apple(models.Model):
    name = models.CharField('品種名', max_length=30, unique=True)

 
リアライザ

class AppleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Apple
        fields = '__all__'

 
ビュー

class AppleViewSet(viewsets.ModelViewSet):
    queryset = Apple.objects.all()
    serializer_class = AppleSerializer

 

HTML側

テンプレート

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Form</title>
</head>
<body>
  <form method="post" action="{% url 'parser_classes_api:apple-list' %}">
    {% csrf_token %}
    <label for="name">品種</label>
    <input type="text" name="name" id="name">

    <button type="submit">送信</button>
  </form>
</body>
</html>

 

全体のルーティング
router = DefaultRouter()
router.register(r'multi-parser', AppleViewSet)
router.register(r'json-only', AppleJsonOnlyViewSet)


urlpatterns = [
    # HTMLの表示
    path('', TemplateView.as_view(template_name='index.html'), name='index'),

    # API
    path('api', include(router.urls)),
]

 

動作

フォームに入力します。

f:id:thinkAmi:20200615082905p:plain:w400

 

送信ボタンを押すと、Django REST FrameworkのThe Browsable APIの画面に遷移し、POSTが成功したことが分かります。 

f:id:thinkAmi:20200615083015p:plain:w400

 
テストコード的には、以下のコードがすべてパスします。

@pytest.mark.django_db
class TestAppleViewSet:
    def test_formをPOSTする(self):
        client = APIClient()
        data = {'name': 'シナノゴールド'}
        actual = client.post(reverse('parser_classes_api:apple-list'),
                             content_type='application/x-www-form-urlencoded',
                             data=urlencode(data))

        assert actual.status_code == HTTPStatus.CREATED

    def test_JSONをPOSTする_content_typeを指定(self):
        client = APIClient()
        data = {'name': 'シナノゴールド'}

        actual = client.post(reverse('parser_classes_api:apple-list'),
                             content_type='application/json',
                             data=json.dumps(data))

        assert actual.status_code == HTTPStatus.CREATED

    def test_JSONをPOSTする_formatを指定(self):
        client = APIClient()
        data = {'name': 'シナノゴールド'}

        actual = client.post(reverse('parser_classes_api:apple-list'),
                             format='json',
                             data=data)

        assert actual.status_code == HTTPStatus.CREATED

 

JSONだけにしたい場合の対応

JSONだけにしたい場合は、

  • settings.py中の DEFAULT_PARSER_CLASSES を変更する
  • 個別に設定する

の2つがあります。

 
今回は個別に設定して、テストコードにて確認してみます。

個別に設定する場合、ビューの parser_classes にParserをタプルで指定します。

class AppleJsonOnlyViewSet(viewsets.ModelViewSet):
    queryset = AppleJsonOnly.objects.all()
    serializer_class = AppleJsonOnlySerializer
    parser_classes = (JSONParser, )

 
パスするテストコードは以下です。

@pytest.mark.django_db
class TestAppleJsonOnlyViewSet:
    def test_formをPOSTする(self):
        client = APIClient()
        data = {'name': 'シナノゴールド'}
        actual = client.post(reverse('parser_classes_api:applejsononly-list'),
                             content_type='application/x-www-form-urlencoded',
                             data=urlencode(data))

        assert actual.status_code == HTTPStatus.UNSUPPORTED_MEDIA_TYPE, 'formからの送信は受け付けない'

    def test_JSONをPOSTする_content_typeを指定(self):
        client = APIClient()
        data = {'name': 'シナノゴールド'}

        actual = client.post(reverse('parser_classes_api:applejsononly-list'),
                             content_type='application/json',
                             data=json.dumps(data))

        assert actual.status_code == HTTPStatus.CREATED

    def test_JSONをPOSTする_formatを指定(self):
        client = APIClient()
        data = {'name': 'シナノゴールド'}

        actual = client.post(reverse('parser_classes_api:applejsononly-list'),
                             format='json',
                             data=data)

        assert actual.status_code == HTTPStatus.CREATED

 

ソースコード

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

Djangoのmodels.ForeignKeyにおけるrelated_nameとrelated_query_nameについて調べてみた

最近、同僚の @qtatsu に「models.ForeignKeyのrelated_nameに + を指定すると逆引き不可にできる」ということを教わって、そういえばこのあたりを理解してないなと思って調べた時のメモです。

 
目次

 

環境

 

models.ForeignKeyにおけるrelated_nameについて

公式ドキュメントはこちら。
ForeignKey.related_name | モデルフィールドリファレンス | Django ドキュメント | Django

ざっくり書くと、「related_nameとは、1対多の関係を持つ2つのモデルにて、1側から多側のオブジェクトを取得する時に、自分の好きな属性名で取得したいときに使うもの」です。

文章だけだと何ともな感じなので、実際に試していきます。

 

ColorとAppleモデルがあり、Color : Apple = 1 : 多 の関係があるとします。Applecolor 属性が外部キーで Color を参照しています。

class Color(models.Model):
    name = models.CharField('色', max_length=30, unique=True)

class Apple(models.Model):
    name = models.CharField('品種名', max_length=30, unique=True)
    color = models.ForeignKey(Color, on_delete=models.CASCADE)

 
この時のデータは以下とします。facotry_boy使ってデータを用意したとします。

red = ColorFactory(name='赤')
yellow = ColorFactory(name='黄')

AppleFactory(name='フジ', color=red)
AppleFactory(name='秋映', color=red)
AppleFactory(name='シナノゴールド', color=yellow)
AppleFactory(name='もりのかがやき', color=yellow)

 
ここで、Colorから関連するAppleを取得したいとします。

ただ、ColorモデルにはAppleに関する情報はどこにもありません。

しかし、Djangoでは

リレーションシップ "反対向き” を理解する

モデルが ForeignKey を持つ場合、外部キーのモデルのインスタンスは最初のモデルのインスタンスを返す Manager にアクセスできます。デフォルトでは、この Manager は FOO_set と名付けられており、FOO には元のモデル名が小文字で入ります。

https://docs.djangoproject.com/ja/3.0/topics/db/queries/#following-relationships-backward

という機能があります。

今回の場合は、 Color.apple_set で、ColorからAppleを参照できます。

そのため、

colors = Color.objects.all()
for color in colors:
    for apple in color.apple_set.all():
        print(f'[{color.name}] {apple.name}')

とすると、

[赤] フジ
[赤] 秋映
[黄] シナノゴールド
[黄] もりのかがやき

という結果が得られます。

 

次に同じようなモデルで related_name を用意してみます。

今回は related_name='my_apple_color' としてみます。

# Colorモデルは同じなので略

class AppleWithRelatedName(models.Model):
    name = models.CharField('品種名', max_length=30, unique=True)
    color = models.ForeignKey(Color, on_delete=models.CASCADE,
                              related_name='my_apple_color')

 
同じように表示してみます。

今回は、 related_name が変わっているので、 color.my_apple_color.all() でアクセスすることになります。

colors = Color.objects.all()
for color in colors:
    for apple in color.my_apple_color.all():  # my_apple_color属性へ変更
        print(f'[{color.name}] {apple.name}')

 
結果は同じです。

[赤] フジ
[赤] 秋映
[黄] シナノゴールド
[黄] もりのかがやき

 

xxx_set ではなくxxx_list としたいけど、related_nameを都度指定したくない」というときは、Metaクラスの default_related_name オプションが使えます。
https://docs.djangoproject.com/ja/3.0/ref/models/options/#django.db.models.Options.default_related_name

default_related_name では直接名前を指定する他、以下の動的な名前も指定できます。

  • '%(class)s' は、フィールドが使用されている子クラスの名前を小文字にした文字列と置換されます。
  • '%(app_label)s' は、子クラスが含まれているアプリ名を小文字にした文字列と置換されます。各インストールアプリケーション名はユニークでなければならず、モデルクラスの名は各アプリ内でもユニークでなければなりません。その結果、すべての名前が異なるものとなります。

https://docs.djangoproject.com/ja/3.0/topics/db/models/#abstract-related-name

 
例えば

class AppleDefaultRelatedName(models.Model):
    name = models.CharField('品種名', max_length=30, unique=True)
    color = models.ForeignKey(Color, on_delete=models.CASCADE)

    class Meta:
        default_related_name = '%(app_label)s_%(class)s_list'

%(app_label)s_%(class)s_list を指定した場合は

colors = Color.objects.all()
for color in colors:
    for apple in color.related_name_appledefaultrelatedname_list.all():
        print(f'[{color.name}] {apple.name}')

と、Colorの属性は related_name_appledefaultrelatedname_list になります。

結果も同じです。

[赤] フジ
[赤] 秋映
[黄] シナノゴールド
[黄] もりのかがやき

 

prefetch_related()の名前も変わる

ちなみに、 related_name を指定した場合は、 prefetch_related() で指定する名前も変更となります。

つまり

class AppleWithRelatedName(models.Model):
    name = models.CharField('品種名', max_length=30, unique=True)
    color = models.ForeignKey(Color, on_delete=models.CASCADE,
                              related_name='my_apple_color')

というモデルの場合は、

print('=== normal x all ===')
colors = Color.objects.all()
for color in colors:
    for apple in color.my_apple_color.all():
        print(f'[{color.name}] {apple.name}')

print('=== prefetch_related ===')
colors = Color.objects.prefetch_related('my_apple_color')
for color in colors:
    for apple in color.my_apple_color.all():
        print(f'[{color.name}] {apple.name}')

のように、 prefetch_related('my_apple_color') となります。

発行されるSQLを見ると、all()の場合は3回です。

(0.000) SELECT "related_name_color"."id", "related_name_color"."name" FROM "related_name_color"; args=()
(0.000) SELECT "related_name_applewithrelatedname"."id", "related_name_applewithrelatedname"."name", "related_name_applewithrelatedname"."color_id" FROM "related_name_applewithrelatedname" WHERE "related_name_applewithrelatedname"."color_id" = 104; args=(104,)
(0.000) SELECT "related_name_applewithrelatedname"."id", "related_name_applewithrelatedname"."name", "related_name_applewithrelatedname"."color_id" FROM "related_name_applewithrelatedname" WHERE "related_name_applewithrelatedname"."color_id" = 105; args=(105,)

 
一方、prefetch_related()の場合は2回で、正しく動いていることが分かります。

(0.000) SELECT "related_name_color"."id", "related_name_color"."name" FROM "related_name_color"; args=()
(0.001) SELECT "related_name_applewithrelatedname"."id", "related_name_applewithrelatedname"."name", "related_name_applewithrelatedname"."color_id" FROM "related_name_applewithrelatedname" WHERE "related_name_applewithrelatedname"."color_id" IN (104, 105); args=(104, 105)

 

逆引きを不可にする

今回ColorからAppleを取得していますが、Djangoではこれを 逆引き と呼ぶようです。
https://docs.djangoproject.com/ja/3.0/topics/db/models/#be-careful-with-related-name-and-related-query-name

 
この逆引きを不可にしたい場合も related_name を使います。

公式ドキュメントにある通り、+ だけ、もしくは + で終わるような名前を指定します。

If you'd prefer Django not to create a backwards relation, set related_name to '+' or end it with '+'.

https://docs.djangoproject.com/ja/3.0/ref/models/fields/#django.db.models.ForeignKey.related_name

class AppleNoReverseWithPlus(models.Model):
    name = models.CharField('品種名', max_length=30, unique=True)
    color = models.ForeignKey(Color, on_delete=models.CASCADE,
                              related_name='+')


class AppleNoReverseWithEndPlus(models.Model):
    name = models.CharField('品種名', max_length=30, unique=True)
    color = models.ForeignKey(Color, on_delete=models.CASCADE,
                              related_name='end_plus+')

 
出力してみます。

colors = Color.objects.all()
for color in colors:
    print(dir(color))

    for apple in color.applenoreversewithplus.all():
        print(f'[{color.name}] {apple.name}')

とすると、 for apple in color.applenoreversewithplus.all(): の行でエラーになります。

AttributeError: 'Color' object has no attribute 'applenoreversewithplus'

 
print(dir(color)) の結果を抜粋すると

[..., 'apple_set',  ..., 'my_apple_color', ..., 'related_name_appledefaultrelatedname_list', ...]

と、+end_plus+ とした時の属性が含まれていません。

これにより、逆引きができなくなっています。

 

1モデル中に、同一モデルに対する外部キー参照が複数ある場合のrelated_name

例えば、 fruit_colorbud_color で、Colorに対する参照を持つとします。

class AppleWith2Color(models.Model):
    name = models.CharField('品種名', max_length=30, unique=True)

    fruit_color = models.ForeignKey(Color, on_delete=models.CASCADE)
    bud_color = models.ForeignKey(Color, on_delete=models.CASCADE)

 
related_name を指定しない場合、マイグレーションを実行するとエラーになります。

$ python manage.py makemigrations
SystemCheckError: System check identified some issues:

ERRORS:
related_name.AppleWith2Color.bud_color: (fields.E304) Reverse accessor for 'AppleWith2Color.bud_color' clashes with reverse accessor for 'AppleWith2Color.fruit_color'.
        HINT: Add or change a related_name argument to the definition for 'AppleWith2Color.bud_color' or 'AppleWith2Color.fruit_color'.
related_name.AppleWith2Color.fruit_color: (fields.E304) Reverse accessor for 'AppleWith2Color.fruit_color' clashes with reverse accessor for 'AppleWith2Color.bud_color'.
        HINT: Add or change a related_name argument to the definition for 'AppleWith2Color.fruit_color' or 'AppleWith2Color.bud_color'.

 
そのため、明示的に related_name を指定します。これにより、マイグレーションができるようになります。

class AppleWith2Color(models.Model):
    name = models.CharField('品種名', max_length=30, unique=True)

    fruit_color = models.ForeignKey(Color, on_delete=models.CASCADE,
                                    related_name='fruits')
    bud_color = models.ForeignKey(Color, on_delete=models.CASCADE,
                                  related_name='buds')

 

Abstractなモデルにて外部キーを使っている場合のrelated_name

Djangoアプリを書く中で、共通な項目をまとめて定義したいことがあります。

その場合

  • 共通で使う項目はAbstractなモデルに定義
  • 実際のクラスは、Abstractなモデルを継承して定義

とするかもしれません。

 
例えば、 bud_color 属性を共通で持たせ、明示的な名前を付けたいとします。

その場合、Abstractなクラスに

class ModelBase(models.Model):
    bud_color = models.ForeignKey(Color, on_delete=models.CASCADE,
                                  related_name='bud_colors')

    class Meta:
        abstract = True

と定義します。

そしてこのクラスを

class Fruit(ModelBase):
    name = models.CharField('品種名', max_length=30, unique=True)

class Potato(ModelBase):
    name = models.CharField('品種名', max_length=30, unique=True)

のように使うとします。

 
しかし、これでは makemigrations時に失敗します。

$ python manage.py makemigrations
SystemCheckError: System check identified some issues:

ERRORS:
related_name.Fruit.bud_color: (fields.E304) Reverse accessor for 'Fruit.bud_color' clashes with reverse accessor for 'Potato.bud_color'.
        HINT: Add or change a related_name argument to the definition for 'Fruit.bud_color' or 'Potato.bud_color'.
related_name.Fruit.bud_color: (fields.E305) Reverse query name for 'Fruit.bud_color' clashes with reverse query name for 'Potato.bud_color'.
        HINT: Add or change a related_name argument to the definition for 'Fruit.bud_color' or 'Potato.bud_color'.
related_name.Potato.bud_color: (fields.E304) Reverse accessor for 'Potato.bud_color' clashes with reverse accessor for 'Fruit.bud_color'.
        HINT: Add or change a related_name argument to the definition for 'Potato.bud_color' or 'Fruit.bud_color'.
related_name.Potato.bud_color: (fields.E305) Reverse query name for 'Potato.bud_color' clashes with reverse query name for 'Fruit.bud_color'.
        HINT: Add or change a related_name argument to the definition for 'Potato.bud_color' or 'Fruit.bud_color'.

 
原因は、公式ドキュメントのこちらです。

related_name と related_query_name の利用に関して注意するべき点

ForeignKey もしくは ManyToManyField に対して related_name または related_query_name を使う場合、そのフィールドに対して 一意の 逆引き名およびクエリ名を常に定義しなければなりません。これは抽象基底クラスにおいて、フィールドが継承した子クラスそれぞれに含まれ、継承される毎にその属性値が完全に同じ値 (related_name と related_query_name も含む) となるため、通常問題となります。

https://docs.djangoproject.com/ja/3.0/topics/db/models/#be-careful-with-related-name-and-related-query-name

 
そのため、Abstractなモデルを変更します。

class ModelBase(models.Model):
    bud_color = models.ForeignKey(Color, on_delete=models.CASCADE,
                                  related_name='%(class)s_bud_colors')

    class Meta:
        abstract = True

これでマイグレーションが実行できます。

 
動作確認です。

pink = ColorFactory(name='ピンク')
purple = ColorFactory(name='紫')

FruitFactory(name='フジ', leaf_color=green, bud_color=pink)
FruitFactory(name='秋映', leaf_color=green, bud_color=pink)
PotatoFactory(name='男爵', leaf_color=green, bud_color=purple)
PotatoFactory(name='メークイン', leaf_color=green, bud_color=purple)

print('=== fruit ===')
fruits_color = Color.objects.prefetch_related('fruit_bud_colors')
for color in fruits_color:
    for fruit in color.related_name_fruit_buds.all():
        print(f'[{color.name}] {fruit.name}')

print('=== potato ===')
potato_colors = Color.objects.prefetch_related('potato_bud_colors')
for color in potato_colors:
    for potato in color.related_name_potato_buds.all():
        print(f'[{color.name}] {potato.name}')

を実行すると、

=== fruit ===
[ピンク] フジ
[ピンク] 秋映
=== potato ===
[紫] 男爵
[紫] メークイン

となりました。

 

公式ドキュメントはこちら
https://docs.djangoproject.com/ja/3.0/ref/models/fields/#django.db.models.ForeignKey.related_query_name

 
DjangoのQuerySet APIでは、1側から多側の項目に対してfilter()やorder_by()を使うこともできます。

その場合、引数に指定する名前は

  • default_related_name
  • related_name

です。

ただ、それらとは名前を別にしたい場合は、 related_query_name を使います。

 
例えば、

class AppleWith3Color(models.Model):
    name = models.CharField('品種名', max_length=30, unique=True)

    bud_color = models.ForeignKey(Color, on_delete=models.CASCADE)
    leaf_color = models.ForeignKey(Color, on_delete=models.CASCADE,
                                   related_name='leaf_colors')
    fruit_color = models.ForeignKey(Color, on_delete=models.CASCADE,
                                    related_name='fruit_colors',
                                    related_query_name='my_fruit_colors')

    class Meta:
        default_related_name = 'default_colors'

というモデルがあったとします。

このモデルに対しQuerySetを使う場合、こんな感じに名前を指定します。

項目名 default_related_name related_name related_query_name 結果
bud_color あり 無し 無し default_related_nameを使う
leaf_color あり あり 無し related_nameを使う
fruit_color あり あり あり related_query_nameを使う

 
実際に試してみます。

まずはデータです。filterで確認しやすいよう、「金のりんご」という除外してほしいデータを用意しました。

red = ColorFactory(name='赤')
yellow = ColorFactory(name='黄')
pink = ColorFactory(name='ピンク')
green = ColorFactory(name='緑')
gold = ColorFactory(name='金')

AppleWith3ColorFactory(name='フジ', fruit_color=red, bud_color=pink, leaf_color=green)
AppleWith3ColorFactory(name='秋映', fruit_color=red, bud_color=pink, leaf_color=green)
AppleWith3ColorFactory(name='シナノゴールド', fruit_color=yellow, bud_color=pink, leaf_color=green)
AppleWith3ColorFactory(name='もりのかがやき', fruit_color=yellow, bud_color=pink, leaf_color=green)
AppleWith3ColorFactory(name='ピンクレディ', fruit_color=pink, bud_color=pink, leaf_color=green)
AppleWith3ColorFactory(name='金のりんご', fruit_color=gold, bud_color=gold, leaf_color=gold)

 
まずは、 default_related_name のみ定義してある場合のfilterです。

default_colors = Color.objects.filter(default_colors__name='フジ')
for color in default_colors:
    for apple in color.default_colors.all():
        print(f'[{color.name}] {apple.name}')

# =>
# [ピンク] フジ
# [ピンク] 秋映
# [ピンク] シナノゴールド
# [ピンク] もりのかがやき
# [ピンク] ピンクレディ

フジと同じ bud_color を持つリンゴだけが抽出されました。

 
続いて、 default_related_namerelated_name を定義してある場合のfilterです。

bud_colors = Color.objects.filter(leaf_colors__name='フジ')
for color in bud_colors:
    for apple in color.leaf_colors.all():
        print(f'[{color.name}] {apple.name}')

# =>
# [緑] フジ
[緑] 秋映
[緑] シナノゴールド
[緑] もりのかがやき
[緑] ピンクレディ

フジと同じ leaf_color を持つリンゴだけが抽出されました。

 
最後に、 related_query_name も定義してある場合のfilterです。

まずは、 related_name で指定してある fruit_colors を使用してみますが、エラーとなります。

fruit_colors = Color.objects.filter(fruit_colors__name='フジ')

# =>
# django.core.exceptions.FieldError: Cannot resolve keyword 'fruit_colors' into field. Choices are: apple, buds, default_colors, fruit_bud_colors, fruits, id, leaf_colors, my_apple_color, my_fruit_colors, name, potato_bud_colors, related_name_appledefaultrelatedname_list

 
続いて、 related_query_name で指定してある my_fruit_colors を使うと動作しました。

[赤] フジ
[赤] 秋映

フジと同じ fruit_color を持つリンゴだけが抽出されました。

 
ちなみに、order_byでもrelated_nameが有効になっています。

print('=== order_by asc ===')
order_by_name = Color.objects.order_by('my_fruit_colors__name')
for color in order_by_name:
    print(color.name)

print('=== order_by desc ===')
order_by_name = Color.objects.order_by('-my_fruit_colors__name')
for color in order_by_name:
    print(color.name)

# =>
=== order_by asc ===
緑
黄
黄
ピンク
赤
赤
金
=== order_by desc ===
金
赤
赤
ピンク
黄
黄
緑

 

ソースコード

GitHubに上げました。 related_name アプリが今回のファイルです。
https://github.com/thinkAmi-sandbox/django_30-sample