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

django-rulesを使って、オブジェクトレベルの認可判定をViewとテンプレートでそれぞれ実装してみた

Djangoには認可機能が標準で用意されています。

ただ、標準の認可機能の場合、モデルごとの認可判定は可能な一方で、オブジェクトごとの認可判定ができないようです。

Djangoパーミッションフレームワークはオブジェクトパーミッション基盤を持っていますが、コアには実装されていません。これにより、オブジェクトパーミッションのチェックは常に False または空のリスト(実行されたチェックに応じていずれか)が返されます。

https://docs.djangoproject.com/ja/3.0/topics/auth/customizing/#handling-object-permissions

 
自作する方法はstackoverflowにありましたが、正直なところ自作したくはありません。
Steps to add model level permission in Django - Stack Overflow

そんな中、jbkingさんの「どじゃんご本#1」を読んだところ、オブジェクトごとに認可判定ができるライブラリとして

  • django-guardian
  • django-rules

の紹介がありました。
どじゃんご本 #1 - どじゃんご@jbking - BOOTH

違いとして、 django-guardian はDBで制御、 django-rules はルールベースでの制御とのことでした。

 
そこで、ルールベースでの制御とはどのようなものか気になったため、 django-rules を試してみることにしました。

 
目次

 

環境

 

認可処理を試すシナリオ

今回は以下のシナリオに沿って試してみます。

ある会社にてお知らせを閲覧するWebアプリが必要になりました。

仕様は

  • ある部門に向けたお知らせを作成する
    • お知らせは部門間でまたがることはない
  • 職務ごとにお知らせの閲覧範囲が異なる
  • ログインしないとお知らせは閲覧できない

です。

職務は

  • 一般
  • 管理職
  • システム管理者

の3種類です。

 
閲覧範囲は

  • ログインしていない場合は、閲覧不可
  • 一般は、自分の所属する部門のお知らせのみ閲覧可能
    • 他部門は、タイトルのみ把握可能
  • 役職者とシステム管理者は、すべてのお知らせを閲覧可能

です。

 
このシナリオを django-rules を使ってDjangoで実装してみます。

 

実装方針

今回は

  1. 職務により、全てのお知らせを閲覧可能 or 一部のお知らせのみ閲覧可能が切り替わること
  2. 一部の職務では、所属部門のお知らせのみ閲覧できること(ただし、タイトルは閲覧可能)

という2つの観点で認可処理を考えることにします。

 
そのため、「それぞれの観点ごとに、ユーザークラスに対して制御用項目を用意する」方針にします。

  • 1.については、 権限タイプ を用意
    • 目的:職務ごとの権限範囲を指定するため
      • 所属部門だけなのか、全体なのか
  • 2.については 所属部門 を用意
    • 目的:どの部門のお知らせを見れるのかを判断するため

 
この方針に従い、 django-rules を使って実装していきます。

なお、スペースの都合上、django-rulesまわりや必要だと思ったところのみ、Blogに記載します。

もし、ソースコード全体を眺めたい場合は、以下となります。
https://github.com/thinkAmi-sandbox/django-rules-sample

 

実装

Djangoプロジェクトとアプリの用意
$ pip install django rules
$ django-admin startproject config .

# ユーザーを管理するアプリ
$ python manage.py startapp accounts

# お知らせを管理するアプリ
$ python manage.py startapp myapp

 

権限タイプを models.IntegerChoices として用意

今回は

  • システム管理者
  • 管理職
  • 一般

の3種類の権限を用意します。

Userモデルに権限タイプを紐付けるため、Django3から使えるようになった models.IntegerChoices を使って定義します。

# accounts/models.py

class PermissionType(models.IntegerChoices):
    ADMIN = (1, 'システム管理者')
    MANAGER = (2, '管理職')
    EMPLOYEE = (3, '一般')

 

部門モデルの作成

フィールドとして name だけを用意しました。

# accounts/models.py

class Section(models.Model):
    name = models.CharField(max_length=100)

 

標準Userモデルを差し替え

属性タイプ・部門をUserモデルに追加するため、標準のUserモデルから AbstractBaseUser を拡張したモデルへと差し替えます。

なお、差し替えるモデルの内容は、 AbstractUser から不要なものをコメントアウトした後、必要なものを追加する形にします。

必要なものは

  • 権限タイプ(permission_type)
  • 部門(section)
  • 表示名 (display_name)

の3項目とします。

# accounts/models.py

class User(AbstractBaseUser, PermissionsMixin):
    username = models.CharField(...)
    display_name = models.CharField(max_length=100)

    # Django3.0からはIntegerChoicesを使える
    permission_type = models.IntegerField(choices=PermissionType.choices,
                                          default=PermissionType.EMPLOYEE)

    section = models.ForeignKey('accounts.Section', on_delete=models.PROTECT)
    # ...

 

お知らせモデルの作成

部門に紐付けるため、 Section モデルを外部キーとして用意します。

# myapp/models.py

class News(models.Model):
    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)

 

ログイン・ログアウト機能の作成

未ログインは閲覧不可、ログイン済は職務に応じて閲覧可能とするため、ログイン機能を作成します。

ログイン・ログアウトのViewは、Django標準のViewを使います。

また、ログイン画面・ログアウト画面のテンプレートは、今回手抜きをしてDjango adminのものを流用します。
Is there a built-in login template in Django? - Stack Overflow

そのため、ログイン・ログアウトまわりのViewはurls.pyだけで完結できます。

# accounts/urls.py

from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path

app_name = 'accounts'

urlpatterns = [
    path('login/',
         LoginView.as_view(template_name='admin/login.html'),
         name='login'),
    path('logout/', LogoutView.as_view(), name='logout'),
]

 

ログイン判定を行うミドルウェアを作成

今回、未ログイン時はどのお知らせも閲覧不可とします。

そのため、ログイン判定処理はミドルウェアにて一括で実行します。

もしログインしていない場合は、ログイン画面へと遷移します。

from django.shortcuts import redirect
from django.urls import reverse


class LoginRequiredMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        return self.get_response(request)

    def process_view(self, request, view_func, view_args, view_kwargs):
        # 全画面ログイン必須なので、ログインしてなかったらログイン画面へ遷移
        if not request.user.is_authenticated and request.path != reverse('accounts:login'):
            return redirect('accounts:login')

 

権限の作成
権限タイプ用

指定したユーザーがどの権限タイプであるかを判定するルールを作成します。
https://github.com/dfunckt/django-rules#creating-predicates

ルールの判定処理は、デコレータ @rules.predicate を付けた関数で行います。

関数の戻り値は

  • True
    • ルールを満たす(=権限あり)
  • False
    • ルールを満たさない(=権限なし)
  • None
    • ルールの判定をパスする(=決定に影響を及ぼさない)

のいずれかです。

例えば、与えられたユーザーが管理者権限を持つかどうかは以下のとおりです。

import rules
from .models import PermissionType

@rules.predicate
def is_admin(user):
    return user.permission_type == PermissionType.ADMIN.value

なお、PermissionTypeクラスは models.IntegerChoices のサブクラスなので、 PermissionType.ADMIN.value とすることで ADMIN = (1, 'システム管理者')1 が取得できます。
https://docs.djangoproject.com/ja/3.0/ref/models/fields/#enumeration-types

 
次に、 rules.add_perm で、指定した権限にルールを割り当てます。
https://github.com/dfunckt/django-rules#setting-up-rules

rules.add_perm('accounts.admin', is_admin)

 
ちなみに、一つの権限に対して、複数のルールをすべて満たす(=AND条件)やいずれかを満たす(=OR条件)を割り当てることもできます。
https://github.com/dfunckt/django-rules#combining-predicates

例えば以下の場合は、is_admin is_manager is_employee のいずれかがTrueであれば accounts.employee の権限があるとみなされます。

rules.add_perm('accounts.employee', is_admin | is_manager | is_employee)

 
権限タイプ用はこんな感じになります。

# accounts/rules.py

import rules
from .models import PermissionType


@rules.predicate
def is_admin(user):
    return user.permission_type == PermissionType.ADMIN.value


@rules.predicate
def is_manager(user):
    return user.permission_type == PermissionType.MANAGER.value


@rules.predicate
def is_employee(user):
    return user.permission_type == PermissionType.EMPLOYEE.value


rules.add_perm('accounts.admin', is_admin)
rules.add_perm('accounts.manager', is_admin | is_manager)
rules.add_perm('accounts.employee', is_admin | is_manager | is_employee)

 

所属部門用

同様にして作成します。

is_same_section() の第二引数 obj は、お知らせクラス(News)のオブジェクトが入ってくる想定です。

# myapp/rules.py

import rules
from accounts.rules import is_admin, is_manager


@rules.predicate
def is_same_section(user, obj):
    return user.section == obj.section


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

 

テンプレートの作成
ベーステンプレート

ログインしているユーザーが見えるよう、以下のようなベーステンプレートを用意します。

<!-- templates/base.html -->

{% block load %}{% endblock %}

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}{% endblock %}</title>
</head>
<body>
<p>ログインユーザ:{{ request.user.display_name }}</p>
<p>所属部署:{{ request.user.section.name }}</p>
<p>権限種類:{{ request.user.permission_type }}</p>

<a href="{% url 'accounts:logout' %}">ログアウト</a>

{% block content %}{% endblock %}
</body>
</html>

 

一覧画面用(django-rulesのテンプレートタグを試す)

一覧画面ではNewsの idtitle だけ表示します。

この画面は一般権限でも閲覧可能としています*1

django-rules のテンプレートタグ has_perm の動作確認をするため、権限ごとに li タグの表示/非表示を切り替えます。https://github.com/dfunckt/django-rules#permissions-and-rules-in-templates

例えば、システム管理者だけが閲覧可能にする方法です。

{% load rules %}
...
{% has_perm 'accounts.admin' request.user as is_admin %}
{% if is_admin %}
    <li>システム管理者だけが見える</li>
{% endif %}

 
{% load rules %}django-rulesを読み込み、{% has_perm 'accounts.admin' request.user as is_admin %} でテンプレートタグ has_perm を使っています。

has_permの引数はそれぞれ

  • 第一引数 'accounts.admin'
    • 権限を指定する
    • ここでは管理者権限があるかを確認
  • 第二引数 request.user
    • ルール用の関数 is_admin(user) に渡すUserオブジェクト

です。

 
あとは、ルール用関数からTrue/Falseのいずれかが返ってくるので、 as で変数に入れて判定を行います。
https://docs.djangoproject.com/en/3.0/howto/custom-template-tags/#django.template.Library.simple_tag

 

全体像はこちら

<!-- templates/myapp/news_list.html -->

{% extends 'base.html' %}

{% block load %}
{% load rules %}
{% endblock %}

{% block title %}お知らせ一覧{% endblock %}

{% block content %}

<h3>お知らせ一覧</h3>

<p>権限によって、表示/非表示を切り替える</p>
<ul>
    {% has_perm 'accounts.admin' request.user as is_admin %}
    {% if is_admin %}
        <li>システム管理者だけが見える</li>
    {% endif %}

    {% has_perm 'accounts.manager' request.user as is_manager %}
    {% if is_manager %}
        <li>管理職以上が見える</li>
    {% endif %}

    {% has_perm 'accounts.employee' request.user as is_employee %}
    {% if is_employee %}
        <li>一般が見える</li>
    {% endif %}
</ul>

<table border="1" rules="all">
    <thead>
    <tr>
        <th>News ID</th>
        <th>タイトル</th>
    </tr>
    </thead>
    <tbody>
    {% for news in object_list %}
        <tr>
            <td>{{ news.id }}</td>
            <td><a href="{% url 'myapp:detail' news.id %}">{{ news.title }}</a></td>
        </tr>
    {% endfor %}
    </tbody>
</table>

{% endblock %}

 

詳細画面

一覧画面と異なり、詳細画面の表示可否はViewで行います。

そのため、テンプレートでは権限による表示制御ロジックは入れていません。

<!-- templates/myapp/news_detail.html -->

{% extends 'base.html' %}

{% block title %}お知らせ詳細{% endblock %}

{% block content %}
<h3>お知らせ詳細</h3>
<p>タイトル: {{ object.title }}</p>
<p>内容: {{ object.content }}</p>

<td><a href="{% url 'myapp:index' %}">一覧へ</a></td>
{% endblock %}

 

403ページ

デフォルトの403ページだと、どのユーザーで403が表示されたのかがわからないため、今回はあえて403ページも自作します。

<!-- templates/403.html -->

{% extends 'base.html' %}

{% block title %}403エラー{% endblock %}

{% block content %}
<p>権限がありません</p>
<td><a href="{% url 'myapp:index' %}">一覧へ</a></td>
{% endblock %}

 

Viewの作成
一覧画面View

権限制御は行わないため、ふつうのListViewです。

class NewsListView(ListView):
    model = News

 

詳細画面View

詳細画面は権限の有無により、画面を表示/非表示を制御します。

そのため、

  • DetailViewに rules.contrib.views.PermissionRequiredMixin を追加
  • 属性として permission_required を追加し、権限を割り当てる

を行います*2
https://github.com/dfunckt/django-rules#using-the-class-based-view-mixin

class NewsDetailView(PermissionRequiredMixin, DetailView):
    model = News
    permission_required = 'myapp.same_section'

今回は、一般職の場合だけ、ユーザーの所属部門がお知らせの部門と一致する場合のみお知らせ詳細を表示するため、 permission_requiredmyapp.same_section を設定しています。

 

settings.pyの修正

ここまでの動作をさせるために、settings.pyを修正します。

django-rules用としては、

です。

主な変更箇所はこちら。

INSTALLED_APPS = [
    # ...
    # rules.pyを自動的に探してくれる書き方
    'rules.apps.AutodiscoverRulesConfig',

    # 自作アプリ
    'accounts.apps.AccountsConfig',
    'myapp.apps.MyappConfig',
]

MIDDLEWARE = [
    ...
    # 末尾にログイン必須ミドルウェアを追加
    'accounts.middlewares.LoginRequiredMiddleware',
]
TEMPLATES = [
    {
        # ...
        # テンプレートディレクトリを修正
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        # ...
    },
]


# 差し替えたユーザーモデル
AUTH_USER_MODEL = 'accounts.User'

# ログインURL
LOGIN_URL = 'accounts:login'
# ログイン後のリダイレクトURL
LOGIN_REDIRECT_URL = 'myapp:index'

# django-rulesの設定
AUTHENTICATION_BACKENDS = (
    'rules.permissions.ObjectPermissionBackend',
    'django.contrib.auth.backends.ModelBackend',
)

 
主な実装箇所は以上です。

細かいところはソースコードを見てください。

 

動作確認

用意したデータ

fixtureとして用意しました。

 
部門は3種類です。この部門ごとに1つのお知らせも用意しました。

  • システム部
  • 製造部
  • 営業部

 
ユーザーは以下の4種類を用意しました。

  • システム管理者
  • 管理職
  • 製造部一般
  • 営業部一般

なお、ユーザーもfixtureで作成しています。

ただ、fixtureだけではユーザーのパスワードを生成できません。

そのため、以下のsolution2を参考に、django shellで生成したパスワードをfixtureに入れてました。
How to add superuser in Django from fixture - Stack Overflow

./manage.py shell

>>> from django.contrib.auth.hashers import make_password
>>> make_password('test')
'pbkdf2_sha256$180000$7bi7okafLu5T$aDHSnnujzykcAbxzibg7MoynKozIF+D1VwS1Kx2mQcg='

 

一覧画面
未ログイン

未ログインでは一覧画面を見れないため、ログイン画面へ遷移します。

f:id:thinkAmi:20200425084844p:plain

 

システム管理者

すべての項目が見えています。

f:id:thinkAmi:20200425084919p:plain

 

役職者

システム管理者の項目が見えなくなりました。

f:id:thinkAmi:20200425084937p:plain

 

製造部一般

一般だけの項目になりました。

f:id:thinkAmi:20200425084953p:plain

 

詳細画面

製造部一覧向けのお知らせを、それぞれのユーザーで閲覧してみます。

未ログイン

一覧同様、ログイン画面へ遷移します。

f:id:thinkAmi:20200425085006p:plain

 

システム管理者

閲覧できます。

f:id:thinkAmi:20200425085019p:plain

 

役職者

閲覧できます。

f:id:thinkAmi:20200425085046p:plain

 

製造部一般

閲覧できます。

f:id:thinkAmi:20200425085103p:plain

 

営業部一般

権限がないため、403ページが表示されています。

f:id:thinkAmi:20200425085119p:plain

 

ソースコード

GitHubに上げました。
https://github.com/thinkAmi-sandbox/django-rules-sample

*1:本来の権限的には一般権限ではタイトルも見えないようにすべきなんですが、今回はサンプルなのでそこまで細かく制御しません

*2:関数ベースビューの場合はデコレータを使います:https://github.com/dfunckt/django-rules#using-the-function-based-view-decorator

Django + django-rules + 独自Userモデルで、has_permテンプレートタグを使うときの注意点

Djangoには標準で認可(Permission)の仕組みがあります。

ただ、Django標準の認可はモデルレベルです。オブジェクトレベルは

Djangoパーミッションフレームワークはオブジェクトパーミッション基盤を持っていますが、コアには実装されていません。

https://docs.djangoproject.com/ja/3.0/topics/auth/customizing/#handling-object-permissions

と公式ドキュメントにあります。

 
そのため、オブジェクトレベルで認可を扱いたい時はライブラリを使います。その選択肢の一つとして django-rules があります。
dfunckt/django-rules: Awesome Django authorization, without the database

 
そんな中、Django + Django-rules + 自作UserモデルのDjangoアプリにて、django-rulesの has_perm テンプレートタグを使おうとした時にハマったため、メモを残します。

 
目次

   

環境

 

問題

PermissionsMixinを継承せずに独自Userモデルを作成したところ、django-rulesによる認可が動作しませんでした。

 

再現方法
独自Userモデルの作成

Django標準のUserモデルではなく、 AbstractBaseUser を継承して独自Userモデルを作成しました。

独自Userモデルを作成する場合、 PermissionsMixin も継承しておくことで、パーミッションまわりの機能を独自ユーザーへ簡単に組み込めます。
https://docs.djangoproject.com/ja/3.0/topics/auth/customizing/#custom-users-and-permissions

ただ、この時は

  • このDjangoアプリが検証目的だったこと
  • django-rulesのREADMEには独自Userモデルのことが書かれていなかったこと

より、 PermissionsMixin を継承しない独自Userモデルを作成しました。

class User(AbstractBaseUser):
    pass

 

INSTALLED_APPS

ドキュメントには

  • rules
  • rules.apps.AutodiscoverRulesConfig

のどちらかを指定するよう記載されています。

ただ、 rules.apps.AutodiscoverRulesConfig を指定しておくと、django-rulesが自動的に rules.py という名前のファイルを探してくれるため、 rules.apps.AutodiscoverRulesConfig を指定します。

rules may optionally be configured to autodiscover rules.py modules in your apps and import them at startup. To have rules do so, just edit your INSTALLED_APPS setting:

https://github.com/dfunckt/django-rules#best-practices

 

AUTHENTICATION_BACKENDS

認証バックエンドとしてdjango-rulesのものが必要なため、settings.pyに追加しておきます。

AUTHENTICATION_BACKENDS = (
    'rules.permissions.ObjectPermissionBackend',
    'django.contrib.auth.backends.ModelBackend',
)

 

rules.py

django-rules用に、 rules.py にルールを実装しました。

import rules

@rules.predicate
def is_admin(user):
    return user.is_admin

rules.add_perm('accounts.admin', is_admin)

 

テンプレートでdjango-rulesを利用

rules をloadした後、 has_perm で権限により表示/非表示を切り替えます。

{% load rules %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>お知らせ</title>
</head>
<body>
<h3>お知らせ一覧</h3>
<ul>
    {% has_perm 'accounts.admin' request.user as is_admin %}
    {% if is_admin %}
        <li>システム管理者だけが見える</li>
    {% endif %}
<ul>

 
ここまででdjango-rulesの設定が終わっているものの、runserverしても has_perm テンプレートタグが動作しませんでした。

 

原因

django-rulesのテンプレートタグ has_permソースコードを見たところ、

@register.simple_tag
def has_perm(perm, user, obj=None):
    if not hasattr(user, 'has_perm'):  # pragma: no cover
        return False  # swapped user model that doesn't support permissions
    else:
        return user.has_perm(perm, obj)

# https://github.com/dfunckt/django-rules/blob/v2.2.0/rules/templatetags/rules.py#L15

と書かれていました。

テンプレートタグ has_perm は、Userモデルに has_perm() メソッドがある前提で動作するようです。

そのため、 PermissionsMixin を継承しないなどでUserモデルに has_perm() メソッドが無い場合は、常に False を返します。

その結果、rules.pyに設定したコードは動作しなかったようです。

 

対応

簡単な対応としては、を継承した独自Userモデルでは PermissionsMixin を継承するようにします。

class User(AbstractBaseUser, PermissionsMixin):
    pass

 
もし、 PermissionsMixin を継承できない場合にテンプレートタグ has_perm を使いたい場合は、何らかの形で独自ユーザークラスに has_perm メソッドを実装しておきます。

 
なお、AbstractBaseUser ではなく AbstractUser を継承した独自ユーザーの場合は PermissionsMixin を継承済のため、このようなことは起きません。
https://github.com/django/django/blob/3.0.5/django/contrib/auth/models.py#L316