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

Djangoのテンプレートで、includeテンプレートタグのwithで渡す値を国際化(i18n)対応する

Djangoのテンプレートにて、

{% include 'translation/parts.html' with value='tsugaru' %}

と、分割した先のテンプレート parts.html に渡した文字列 tsugaru を国際化しようとした時に悩んだのでメモを残します。

 
目次

 

環境

 

Djangoテンプレートでの国際化対応について

本題に入る前に、Djangoテンプレートの国際化(i18n)対応について、簡単に流れを書いておきます。

Djangoテンプレートの国際化で必要なものは、主に

の3つです。

 
それらを用意する流れは以下の通りです。

  1. settings.pyに設定
  2. テンプレートにて、テンプレートタグを使ってマーキング
  3. メッセージファイルを生成
  4. メッセージファイルを編集
  5. メッセージファイルをコンパイル
  6. 表示

 
ここでは「Djangoのテンプレートにて fuji という文字列を日本語の場合は フジ と表示する」を例に、流れをメモしておきます。

 

settings.pyに設定

Djangoで使うデフォルト言語は、settings.pyの LANGUAGE_CODE に指定します。
https://docs.djangoproject.com/en/3.0/ref/settings/#language-code

LANGUAGE_CODE = 'ja'

の場合、日本語がデフォルト言語となります。

 
また、ミドルウェア LocaleMiddleware を使うことで複数の言語を扱えるようになります。

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',

    # i18n用ミドルウェア
    'django.middleware.locale.LocaleMiddleware',

    'django.middleware.common.CommonMiddleware',
    ...
]

 
例えば、URLに言語コードを指定することで特定の言語を表示させたい場合

は、上記ミドルウェアに加え、 urls.py にて

from django.conf.urls.i18n import i18n_patterns

urlpatterns = i18n_patterns(
    path('translation', include('translation.urls')),
)

i18n_patterns を使えばよいです。
https://docs.djangoproject.com/en/3.0/topics/i18n/translation/#django.conf.urls.i18n.i18n_patterns

 
他に、 LOCALE_PATHS を使い、i18n用のメッセージファイル置き場も指定できます。
https://docs.djangoproject.com/en/3.0/ref/settings/#locale-paths

LOCALE_PATHS = (
    os.path.join(BASE_DIR, 'locale'),
)

の場合、プロジェクトルート下の locale ディレクトリにメッセージファイルが格納されます。

 

テンプレートにて、テンプレートタグを使ってマーキング

テンプレートにて

{% load i18n %}

<p>[Index - trans版]{% trans 'fuji' %}</p>

と、

  • load i18n でロード
  • マーキングとして trans テンプレートタグの引数に翻訳したい文字列を指定
    • マーキング

を行います。
https://docs.djangoproject.com/en/3.0/topics/i18n/translation/#trans-template-tag

 

メッセージファイルの生成

django-admin makemessages コマンドでメッセージファイルを生成します。
https://docs.djangoproject.com/en/3.0/topics/i18n/translation/#message-files

$ django-admin makemessages -l ja
processing locale ja

とすると、 locale/ja/LC_MESSAGES/django.po ファイルが作成されます。

 

メッセージファイルの編集

django.poファイルを開くと、どのファイルのどの箇所でどれが使われているかが設定されています。

#: templates/translation/index.html:7
msgid "fuji"
msgstr ""

は、templates/translation/index.htmlの7行目に fuji という文字列がマーキングされています。

そこで、翻訳後の文字列を msgstr に指定します。

#: templates/translation/index.html:7
msgid "fuji"
msgstr "フジ"

の場合、 fuji という文字列が フジ に翻訳されます。

 

コンパイル

django-admin compilemessages コマンドでコンパイルします。
https://docs.djangoproject.com/en/3.0/topics/i18n/translation/#compiling-message-files

$ django-admin compilemessages
...
processing file django.po in /path/to/env/lib/python3.8/site-packages/django/contrib/flatpages/locale/es/LC_MESSAGES

コンパイル後、django.po ファイルと同じディレクトリに、 django.mo ファイルが生成されます。

 

動作確認

Djangoを起動して確認します。

http://localhost:8000/ja/translation にアクセスすると、django.mo ファイルのある日本語 (ja) の場合は翻訳されています。

f:id:thinkAmi:20200531180335p:plain:w300

 
一方、http://localhost:8000/ja/translation にアクセスすると django.mo ファイルのない英語 (en) の場合は、テンプレートのままになっています。

f:id:thinkAmi:20200531180457p:plain:w300

 
以上が、Djangoにおける国際化 (i18n) 対応の簡単な流れです。

 

includeテンプレートタグのwithに渡す値の国際化

本題です。

Djangoテンプレートタグの include では with を使うことで読み込むテンプレートに値を渡せます。
https://docs.djangoproject.com/en/3.0/ref/templates/builtins/#include

例えば、 parts.html

<p>[Parts - そのまま版]{{ value }}</p>

を書き、それをindex.htmlで読み込むようにします。

その際、 with value='tsugaru' とすることで、parts.htmlのテンプレート変数 value に、 tsugaru という値を渡せます。

{% include 'translation/parts.html' with value='tsugaru' %}

 
今回は tsugaru という文字列を国際化したいとします。

 

うまくいかない方法
テンプレートタグをネストする

まず、単純にテンプレートタグをネストするだけでは、Djangoテンプレートのシンタックスエラーになります。

{% include 'translation/parts.html' with value={% trans 'hello' %} %} 

エラー

TemplateSyntaxError at /en/translation

Could not parse the remainder: '{%' from '{%'

 

includeされる側で翻訳する

続いて、index.html側では

{% include 'translation/parts.html' with value='tsugaru' %}

として、parts.html側で翻訳することを考えます。

 
まず

<p>{% trans {{ value }} %}</p>

としてもエラーになります。

Could not parse the remainder: '{{' from '{{'

trans テンプレートタグの中では変数を展開できないためです。

 
その代わりに transblock テンプレートタグを使います。
https://docs.djangoproject.com/en/3.0/topics/i18n/translation/#blocktrans-template-tag

<p>[Parts - blocktrans版]{% blocktrans %}{{ value }}{% endblocktrans %}</p>

 
次に、django-admin makemessages -l ja コマンドで翻訳ファイルを更新します。すると

#: templates/translation/parts.html:4
#, python-format
msgid "%(value)s"
msgstr ""

が追加されます。

ただ、この部分を

#: templates/translation/parts.html:4
#, python-format
msgid "%(value)s"
msgstr "foo"

とした場合、 django-admin compilemessages でのコンパイルに失敗します。

$ django-admin compilemessages
...
processing file django.po in /path/to/django project/locale/ja/LC_MESSAGES
Execution of msgfmt failed: /path/to/django project/locale/ja/LC_MESSAGES/django.po:1212: 引数 'value' に対する形式指定' に存在しません
msgfmt: 1 個の致命的エラーが見つかりました
...
CommandError: compilemessages generated one or more errors.

 
エラーは msgstr "foo %(value)s" のように変数valueの値 %(value)s を含めれば解消されます。

しかし、これでは value に渡された値は翻訳されません。

f:id:thinkAmi:20200531183446p:plain:w300

 
 

うまくいく方法

includeする側 (例の場合だと index.html側) で翻訳し、includeされる側に翻訳後の値を渡せば良いです。

翻訳する方法としては以下の2つがあります。

  • transで翻訳後、 as で変数に保存する
  • _() を使う

 
なお、渡される側(parts.html) は

<p>[Parts - そのまま版]{{ value }}</p>

と、値をそのまま表示します。

 

transで翻訳後、 as で変数に保存する

テンプレートタグ trans には、翻訳後の値を変数に入れておくために as が使えます。
https://docs.djangoproject.com/en/3.0/topics/i18n/translation/#trans-template-tag

 
今回の例では

{# akibaeを翻訳し、 ringo に入れる #} 
{% trans 'akibae' as ringo %}

{# 翻訳後の値をparts.htmlに渡す #}
{% include 'translation/parts.html' with value=ringo %}

となります。

 

_() を使う

今回のように文字列リテラルの場合には、 _() も使えます。
https://docs.djangoproject.com/en/3.0/topics/i18n/translation/#string-literals-passed-to-tags-and-filters

{# shinano-dulce を _() で翻訳する #}
{% include 'translation/parts.html' with value=_('shinano-dulce') %}

 

動作確認

このように、両方とも翻訳できました。

f:id:thinkAmi:20200531201628p:plain:w300

 

ソースコード

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

Django REST Frameworkで、 django-rulesを使ってみた

前回、Djangodjango-rules を使ってみました。
django-rulesを使って、オブジェクトレベルの認可判定をViewとテンプレートでそれぞれ実装してみた - メモ的な思考的な

 
READMEには、Django REST Framework(以降、DRF)でも、 django-rulesが使えるとの記述がありました。
Permissions in Django Rest Framework | dfunckt/django-rules: Awesome Django authorization, without the database

このコメントによると、2019年8月に機能追加されたようです。

 
DRFの場合は ModelViewSet で使えるとあったため試してみました。

 
目次  

 

環境

 
なお、検証用のアプリは前回のソースコードに追加していきます。

 

実装

今回、 ReadOnlyModelViewSet を使ったViewSetに対し、django-rulesが使えるかを確認します。

 

Django REST Frameworkアプリの作成

api というアプリを作成します。

$ pip install djangorestframework
$ python manage.py startapp api

 

rules.pyの追加

前回同様、

  • システム管理者は閲覧可能
  • 管理職も閲覧可能
  • 一般は、自分と同じ部署のみ閲覧可能

とします。

DRFdjango-rules を使う場合、

rules.add_perm('myapp.same_section', is_same_section | is_admin | is_manager)

とadd_permする必要はなく、認可するかどうかの関数のみを用意します。

そのため、上記と同じ意味を持つ関数 can_view() を用意します。

@rules.predicate
def can_view(user, obj):
    return is_same_section(user, obj) or is_admin(user) or is_manager(user)

 

モデルの追加

前回はViewにパーミッションを追加しました。

DRFで使う場合にはモデルにパーミッションの追加が必要です。

Meta クラスの中に rules_permissions を追加し、設定を行います。

Djangoのデフォルトでは、一覧(ListView)を除く、

  • view
  • add
  • change
  • delete

パーミッションが用意されています。
デフォルトの権限 | Djangoの認証システムを使用する | Django ドキュメント | Django

そのため、今回は rules_permissions のキーとして view を、値には先ほど定義した rules.py にある can_view を指定します。

また、モデルの継承元を RulesModel にします。django-rulesのドキュメントには、状況に応じて継承するクラスを変えるよう記載があります。

from rules.contrib.models import RulesModel

class DrfNews(RulesModel):
    title = models.CharField(max_length=50, verbose_name='タイトル')
    content = models.CharField(max_length=100, verbose_name='内容')
    section = models.ForeignKey('accounts.Section', on_delete=models.PROTECT)

    class Meta:
        rules_permissions = {
            'view': can_view
        }

 

リアライザの追加

ふつうのシリアライザを用意します。

class NewsSerializer(serializers.ModelSerializer):
    class Meta:
        model = DrfNews
        fields = '__all__'

 

ViewSetの追加

django-rulesはViewSetで使えます。そのため、ViewSetを追加します。

ViewSetで使う場合には、 AutoPermissionViewSetMixin も継承します。

class NewsReadOnlyModelViewSet(AutoPermissionViewSetMixin, ReadOnlyModelViewSet):
    queryset = DrfNews.objects.all()
    serializer_class = NewsSerializer

 
なお、 ReadOnlyModelViewSet 以外にも、 ModelViewSet や、Mixinを減らしたViewSetでも同様に使えます。

class NewsRetrieveModelViewSet(AutoPermissionViewSetMixin,
                               mixins.RetrieveModelMixin,
                               GenericViewSet):
    queryset = DrfNews.objects.all()
    serializer_class = NewsSerializer

 

urls.pyの追加

こちらもふつうのViewSet向けのurls.pyとなります。

router = routers.SimpleRouter()
router.register('news', NewsReadOnlyModelViewSet)
router.register('retrieve', NewsRetrieveModelViewSet)

urlpatterns = [
    path('', include(router.urls)),
]

 

settings.pyの追加

DRF向けの設定を追加します。

なお、DRFによるAPIも認証後に認可判定を行います。

今回はBrowsable APIで動作確認するため、確認が楽なCookie認証にしておきます。

INSTALLED_APPS = [
    # 自作アプリ
    'api.apps.ApiConfig',

    # DRF
    'rest_framework',
]

# DRF向け
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
    ]
}

 

動作確認

システム管理者

アクセス可能です。

f:id:thinkAmi:20200426220800p:plain:w400

 

管理職

アクセス可能です。

f:id:thinkAmi:20200426220829p:plain:w400

 

一般 (製造部)

アクセス可能です。

f:id:thinkAmi:20200426220846p:plain:w400

 

一般 (営業部)

アクセスできず、403が表示されています。

f:id:thinkAmi:20200426220909p:plain:w400

 

ソースコード

GitHubに追加しました。 api ディレクトリあたりが今回のファイルです。
https://github.com/thinkAmi-sandbox/django-rules-sample