mizzsugar’s blog

日々感じていることや学んだことを書きます。エンジニアリング以外にも書くかもしれません。

【Django】複合ユニーク制約を実装する

Djangoで複合主キーっぽいことをやりたくて。

使用技術

* Django 2.2.1
* Python 3.7.3


Djangoでは単一主キーのみをサポートしているため複合主キーはできないのですが 複合ユニーク制約ならできるようです。

Django2.2にて、MetaオプションにUniqueConstraintというものが登場しました。


models.py

from django.db import models


class Reservation(models.Model):
    room = models.CharField(verbose_name="部屋名", max_length=20)
    date = models.DateField(verbose_name="予約日")

    class Meta:
        constraints = [
            # 同じ日に部屋の予約を重複させない
            models.UniqueConstraint(fields=['room', 'date'], name='unique_booking'),
        ]

        

※こちらよりコードを引っ張りました。

Django 2.2 LTS 主な変更点まとめ – CreditEngine Tech – Medium


class Metaにて、constraintsに制約を設定するオブジェクトの配列を代入します。

上記では、UniqueConstraintの引数「fields」に設定した'room', 'date'の2つのカラムが複合ユニークとなります。

https://docs.djangoproject.com/ja/2.2/ref/models/constraints/#django.db.models.UniqueConstraint


なお、UniqueConstraintに関してはバリデーションの代わりとはならず、既存組み合わせのレコードを登録しようとするとIntegurityErrorが発生します

>>> import datetime
>>> from .models import Reservation
>>> # 正常にcreateが実行される
Reservation.objects.create(room='room_a', date=datetime.date(2019, 6, 1))

>>> # 既存のroomとdateの組み合わせをcreateしようとするとIntegurityErrorとなる
Reservation.objects.create(room='room_a', date=datetime.date(2019, 6, 1))
# django.db.utils.IntegrityErrorが発生する


なので、バリデーションをしたい場合は

例えば下記のように実装します。

まずは指定されたroomとdateの組み合わせがすでにDBに登録されているか確認するメソッドを作成します。

models.py

import datetime

from django.db import models


class Reservation(models.Model):
    room = models.CharField(verbose_name="部屋名", max_length=20)
    date = models.DateField(verbose_name="予約日")

    class Meta:
        constraints = [
            # 同じ日に部屋の予約を重複させない
            models.UniqueConstraint(fields=['room', 'date'], name='unique_booking'),
        ]
    
    # 追加
    @classmethod
    def check_duplicate(cls, room: str, date: datetime.date) -> bool:
        """同じ日に同じ部屋がすでにDBに登録されているどうかを判定します

        登録されていたらTrue, されていなかったらFalseを返します。
        """
        return cls.objects.filter(room=room, date=date).exists()


続いて、フォームで下記のように実装します。

今回はdjango.formsのFormとModelFormの両方で作成してみました。

どちらを使ってもroomとdateの重複をフォームのバリデーションの時点で確認できることが分かりました。

forms.py

import datetime

from django import forms

from .models import Reservation


class ReservationForm(forms.Form):
    room = forms.CharField(max_length=20)
    date = forms.DateField()

    def clean(self):
        cleaned_data = super(ReservationForm, self).clean()
        
        room = cleaned_data.get("room")
        date = cleaned_data.get("date")

        if Reservation.check_duplicate(room=room, date=date):
            raise forms.ValidationError(f'{date}に{room}はすでに予約されているため登録できません')

        return cleaned_data


class ReservationModelForm(forms.ModelForm):
    class Meta:
        model = Reservation
        fields = ['room', 'date']


python manage.py shellで実行すると下記のようになりました。

>>> # python manage.py shellにて
>>> # DBには room='room_a', date=datetime.date(2019, 6, 1)のレコードがすでに存在する状態

>>> from app.forms.reservation_form import ReservationForm, ReservationModelForm


>>> # まずはforms.Formで実装したフォーム

>>> form_a = ReservationForm({'room':'room_a', 'date':datetime.date(2019,6,1)})
>>> form_a.is_valid()
False

>>> form_a.errors
{'__all__': ['2019-06-01にroom_aはすでに予約されているため登録できません']}
>>> # forms.ValidationErrorで設定したエラーメッセージが返される

>>> # 続いてforms.ModelFormで実装したフォーム

>>> form_b = ReservationModelForm({'room': 'room_a', 'date': datetime.date(2019, 6, 1)})
>>> form_b.is_valid()
False

>>> form_b.errors
{'__all__': ['この 部屋名 と 予約日 を持った Reservation が既に存在します。']}
>>> # DjangoのModelFormでデフォルトで実装されているエラーメッセージが返される


また、Metaオプションで複合ユニーク制約する方法として unique_togetherというものもあります。

ただし、こちらに関しては将来廃止される可能性もあると公式ドキュメントにも書いているので Django2.2以降をご利用の場合はUniqueConstraintで実装するようにしましょう。

https://docs.djangoproject.com/en/2.2/ref/models/options/#unique-together


UniqueConstraintのついでに複合インデックスもやってくれてないかなと思って調べたところ、 複合ユニーク制約のみで複合インデックスは別に実装する必要があるようです。

Metaオプションにindexesを追加します。 indexesはmodel.Indexを要素とする配列となります。

Indexの引数fieldsに複合インデックスとしたいフィールドの名前からなる配列を代入してください。

models.py

from django.db import models


class Reservation(models.Model):
    room = models.CharField(verbose_name="部屋名", max_length=20)
    date = models.DateField(verbose_name="予約日")

    class Meta:
        constraints = [
            # 同じ日に部屋の予約を重複させない
            models.UniqueConstraint(fields=['room', 'date'], name='unique_booking'),
        ]

        # 複合インデックスの実装
        indexes = [
            models.Index(fields=['room', 'date'])
        ]


複合インデックスを設定する方法でMetaクラスにindex_togetherを設定する方法がありますが、 こちらは将来廃止される可能性もあると公式ドキュメントにも書いているので Django2.2以降をご利用の場合はindexesで実装するようにしましょう。

https://docs.djangoproject.com/en/2.2/ref/models/options/#index-together

【Python】GoogleAPIを利用したメソッドのテストでモックを使う

外部APIを利用したメソッドの単体テスト書きたいなあ・・・ 

単体テストで何回も外部APIに接続するのはお行儀悪いって聞くけどどう書けば良いのだろう?と思い。

簡単な機能のテストを書きました。

ちょっと変更しましたが、Google Calendar APIのquick startでやっていることをやります。

developers.google.com


一つのメソッドが長いので下記のような役割のメソッドに分割しました。

  1. pickleに保存した認証情報をロードする

  2. 認証情報が存在しないor有効期限切れならば認証情報を再生成する

  3. イベント情報を取得する役割のオブジェクトを生成する

  4. イベント情報を取得する


今回テストするプロダクトコード↓

event.py

from dataclasses import dataclass
from typing import Iterable, Optional, Dict
import datetime
import pickle
import os.path
import pathlib

import googleapiclient.discovery
import google_auth_oauthlib.flow
from google.auth.transport.requests import Request
import google.oauth2.credentials
import iso8601


# 環境変数にした方がよいですが、ブログではわかりやすいように表示しています
SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']


class ServiceBuilder:
    @classmethod
    def renew_token(cls, credentials: google.oauth2.credentials.Credentials) -> google.oauth2.credentials.Credentials:
        # google.oauth2.credentials.Credentialsは外部APIのクラス
        if credentials.expired and credentials.refresh_token:
            # 外部APIに接続
            credentials.refresh(Request())
        else:
            # from_client_secrets_file returns google_auth_oauthlib.flow.Flow object
            flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(
                'credentials.json', SCOPES)
            credentials = flow.run_local_server()
        return credentials

    @classmethod
    def build(cls, credentials: google.oauth2.credentials.Credentials) -> googleapiclient.discovery.Resource:
        """トークンを元にServiceを生成します
        """

        # If there are no (valid) credentials available, let the user log in.
        # TODO: 42-43はrenew_tokenの先頭の方がよい
        if not credentials.valid:
            credentials = cls.renew_token(credentials)
        
        # 外部APIに接続
        return googleapiclient.discovery.build('calendar', 'v3', credentials=credentials)


@dataclass
class Event:
    summary: str
    start: datetime.datetime
    end: datetime.datetime


class EventAdapter:
    @classmethod
    def from_google_api(cls, google_event: Dict) -> Event:
        """GoogleAPIから取得したDict型のイベント情報をEventオブジェクトに変換します

        Dict型のままだと利用しない情報も含まれている、かつネストが深くて使いづらいため

        iso8601について
        https://pypi.org/project/iso8601/
        """
        return Event(
            summary=google_event.get('summary'),
            start=iso8601.parse_date(google_event.get('start').get('dateTime')),
            end=iso8601.parse_date(google_event.get('start').get('dateTime'))
        )


class EventManager:
    @classmethod
    def fetch_events(cls, number: int, service: googleapiclient.discovery.Resource) -> Iterable[Event]:
        """Google Calendar APIからイベントを取得します。

        number: int 取得するイベントの最大数
        service: イベントを取得するResourceオブジェクト
        """
        # service = ServiceBuilder.build()
        now = datetime.datetime.utcnow().isoformat() + 'Z' # 'Z' indicates UTC time
        print('Getting the upcoming 10 events')
        events_result = service.events().list(calendarId='primary', timeMin=now,
                                            maxResults=10, singleEvents=True,
                                            orderBy='startTime').execute()
        events = events_result.get('items', [])

        if not events:
            return []
        return [EventAdapter.from_google_api(event) for event in events]

main.py

import pathlib
from typing import Optional
import pickle

import google.oauth2.credentials

import event


def load_token(file: pathlib.Path) -> Optional[google.oauth2.credentials.Credentials]:
    """トークンをロードします。

    pickleの中にtokenがなければNoneを返します。
    """
    try:
        # pathlib.Path('token.pickle').read_bytes()をモックしたテスト
        with file.open('rb') as f:
            return pickle.load(f)
    except FileNotFoundError:
        return None


def main():
    token_file = pathlib.Path('token.pickle')
    credentials = event.ServiceBuilder.renew_token(credentials=load_token(token_file))

    with token_file.open('wb') as f:
        pickle.dump(credentials, f)
    
    service = event.ServiceBuilder.build() # missing one argument
    events = event.EventManager.fetch_events(number=2, service=service)
    print(events)

if __name__ == '__main__':
    main()


テストしたいこと

  1. 認証情報が有効切れor認証情報がpickleにない場合は新たにトークンが生成されるかどうか

  2. fetch_eventsでイベントが取得されるかどうか。

  3. fetch_eventsでGoogle Calendar APIから取得されたイベントが0件の場合は空の配列が返されるかどうか


from_google_apiとload_tokenに関しては

ちゃんとインスタンスができているか? 例外を発生するか?

といったようにPythonの仕様の確認になってしまうのでテストしないことにしました


1. 認証情報が有効切れor認証情報がpickleにない場合は新たにトークンが生成されるかどうか

まず、どこが外部APIに接続しているか調べます。

class ServiceBuilder:
    @classmethod
    def renew_token(cls, credentials: google.oauth2.credentials.Credentials) -> google.oauth2.credentials.Credentials:
        # google.oauth2.credentials.Credentialsは外部APIのクラス
        if credentials.expired and credentials.refresh_token:
            credentials.refresh(Request())  # 外部APIに接続
        else:
            # from_client_secrets_file returns google_auth_oauthlib.flow.Flow object
            flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(
                'credentials.json', SCOPES)   # 外部APIに接続
            credentials = flow.run_local_server()   # 外部APIに接続
        return credentials


「外部APIに接続」の部分をモックすれば人様のAPIに迷惑をかけない!

renew_token 自体をテストしようとしました。

でも、renew_token自体をテストしても良いけれども、 renew_tokenをまるっとモックしてServiceBuilder.buildで確認しちゃった方が楽だなあと。

credentailsの状態によってbuildの振る舞いが変わるので credentialsのモックを作り、credentialsモックの状態がそれぞれ異なる場合のテストを作ります。

test.py

@unittest.mock.patch('event.ServiceBuilder.renew_token')
@unittest.mock.patch('googleapiclient.discovery.build')
def test_valid_token(mock_build, mock_renew_token):
    credentials = unittest.mock.Mock(valid=True)

    event.ServiceBuilder.build(credentials=credentials)
    assert not mock_renew_token.called

@unittest.mock.patch('event.ServiceBuilder.renew_token')
@unittest.mock.patch('googleapiclient.discovery.build')
def test_expired_token_has_refresh_token(mock_build, mock_renew_token):
    credentials = unittest.mock.Mock(valid=False, expired=True, refresh_token='I am refresh token')

    event.ServiceBuilder.build(credentials=credentials)
    assert  mock_renew_token.called

credentailsの状態が妥当でない場合に認証情報が再生成されるメソッドが呼ばれるかどうかを確認できました!


2. fetch_eventsでイベントが取得されるかどうか。

3. fetch_eventsでGoogle Calendar APIから取得されたイベントが0件の場合は空の配列が返されるかどうか

イベントの取得のメソッドテストでは、イベントをちゃんと取得できるかのみを確認したいです。

serviceにGoogle Calendar APIからイベントを取得させていますが、serviceに利用されているcredentialsが有効期間切れでrefresh_tokenが実行されてしまう・・・

なんてことがあったら面倒です。


なので、fetch_eventsの外でserviceを生成させ、serviceをfetch_eventsの引数にします。

fetch_eventsのテストでは、serviceをモックし、serviceが正常に動くことを前提とすることにしました。

Google Calendar APIのドキュメントを確認すると、 イベント取得メソッドは下記のようになっています。

service.events().list(calendarId='primary', timeMin=now,
                                        maxResults=10, singleEvents=True,
                                        orderBy='startTime').execute()

https://developers.google.com/calendar/quickstart/python


serviceって何クラスのオブジェクトだと調べました。

from googleapiclient.discovery import build

service = build('calendar', 'v3', credentials=creds)


serviceはbuildによって生成されたオブジェクトらしい・・・

googleapiclient.discoveryのbuildはResourceオブジェクトを返すそうです。

https://github.com/googleapis/google-api-python-client/blob/master/googleapiclient/discovery.py#L170


Resourceを探したところ、

googleapiclient.discoveryのResourceだとわかりました。

https://github.com/googleapis/google-api-python-client/blob/master/googleapiclient/discovery.py#L995


ということでserviceはResourceオブジェクトをモックしよう。

service.events().list(calendarId='primary', timeMin=now,
                                        maxResults=10, singleEvents=True,
                                        orderBy='startTime').execute()


上記をもってGoogle Calendar APIからイベント情報のDictが返ってきます。

ということで、serviceをモックしてこんな感じでイベント情報を返すとします。

実際のAPIではもっとたくさんの情報が返されますが

テストに必要な情報のみにしました。

https://developers.google.com/calendar/v3/reference/events/list

mock_resource.events.return_value.list.return_value.execute.return_value = {
        "items": [
                    {
                        "summary": "test",
                        "start": {
                            "dateTime": "2019-06-03T02:00:00+09:00"
                        },
                        "end": {
                            "dateTime": "2019-06-03T02:45:00+09:00"
                        },
                    },
                        {
                        "summary": "test",
                        "start": {
                            "dateTime": "2019-06-03T02:00:00+09:00"
                        },
                        "end": {
                            "dateTime": "2019-06-03T02:45:00+09:00"
                        },
                    },
        ]
    }

そして2と3のテストを

@unittest.mock.patch('googleapiclient.discovery.Resource')
def test_fetch_events(mock_resource):
    mock_resource.events.return_value.list.return_value.execute.return_value = {
        "items": [
                    {
                        "summary": "test",
                        "start": {
                            "dateTime": "2019-06-03T02:00:00+09:00"
                        },
                        "end": {
                            "dateTime": "2019-06-03T02:45:00+09:00"
                        },
                    },
                        {
                        "summary": "test",
                        "start": {
                            "dateTime": "2019-06-03T02:00:00+09:00"
                        },
                        "end": {
                            "dateTime": "2019-06-03T02:45:00+09:00"
                        },
                    },
        ]
    }
    actual = event.EventManager.fetch_events(number=2, service=mock_resource)
    assert 2 == len(actual)

    for item in actual:
        assert isinstance(item, event.Event)


@unittest.mock.patch('googleapiclient.discovery.Resource')
def test_fetch_no_events(mock_resource):
    mock_resource.events.return_value.list.return_value.execute.return_value = {}

    actual = event.EventManager.fetch_events(number=2, service=mock_resource)
    assert [] == actual


ポイントは

  • 外部APIのオブジェクトは何かを調べてそのクラスのオブジェクトをモックすること

  • return_valueをどう実行するか

だと思いました。

外部APIのソースをある程度読むことになり、少し大変でしたが 勉強になりました。


今回のソースコードはこちら

github.com


また、NaritoさんがDjangoを利用してGoogle Calendar APIと連携したアプリケーションを作成されていました。

https://torina.top/detail/466/


近いうちにDjangoGoogle Calendar APIのテストの記事も書こうかなと思います。

(内容はあまり変わらないと思いますが)

【Django】フォームのカスタムバリデーションをテストする

Djangoのフォームで独自のバリデーションを実装した際のテスト方法を紹介します。 間違いがありましたら、ご指摘お願いします(>人<)

今回使ったバージョン

* Python 3.7.1
* Django 2.1.7


今回実装するフォームはこちら

forms.py
import re

from django import forms
from django.core.exceptions import ValidationError


def is_hiragana(value):
    pattern = r'^[ぁ-ん]+$'
    if not re.fullmatch(pattern, value):
        raise ValidationError('ひらがなで書いてください')


def validate_chatwork_id(value):
    pattern = r'^[0-9]+$'
    if not re.fullmatch(pattern, value) or len(value) != 7:
        raise ValidationError('半角数字7文字で書いてください')


class EmployeeForm(forms.Form):
    name = forms.CharField(
        max_length=225,
        label='名前',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    name_kana = forms.CharField(
        max_length=225,
        label='名前(かな)',
        validators=[is_hiragana],
        required=False,
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    chatwork_id = forms.CharField(
        label='チャットワークID',
        validators=[validate_chatwork_id],
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
テストしたいこと
  • 名前(かな)にひらがな以外が含まれていたらエラーを表示したい

  • チャットワークIDが半角数字7文字以外だったらエラーを表示したい


form.is_valid()でTrueかFalseを返すテストは良くみるんだけど、

正しくエラーメッセージが出力されるかの方法がわからん・・・


とりあえず書いてみた。

from django.core.exceptions import ValidationError
from django.test import TestCase
from .forms import EmployeeForm


class TestEmployeeForm(TestCase):
    @classmethod
    def setUpClass(cls):
        pass

    def test_invalid_form_not_hiragana(self):
        params = dict(
            name='サンプル太郎',
            name_kana='サンプルタロウ',
            chatwork_id='0123456'
        )
        form = EmployeeForm(params)
        form.is_valid()
        self.assertEqual(
            'ひらがなで書いてください',
            form.errors
        )

    @classmethod
    def tearDownClass(cls):
        pass


結果、怒られる。

AssertionError: 'ひらがなで書いてください' != {'name_kana': ['ひらがなで書いてください']}


Dict[List]か・・・なら・・・、と試しにやってみる。

    def test_invalid_form_not_hiragana(self):
        params = dict(
            first_name='サンプル太郎',
            first_name_kana='サンプルタロウ',
            chatwork_id='0123456'
        )
        form = EmployeeForm(params)
        form.is_valid()
        self.assertTrue(
            'ひらがなで書いてください' in form.errors['name_kana']
        )


できた!!!


同じ要領でchatwork_idもやってみると

    def test_invalid_form_chatwork_id(self):
        with self.subTest('半角数字以外を含むとエラーになる'):
            params = dict(
                first_name='サンプル太郎',
                first_name_kana='さんぷるたろう',
                chatwork_id='012aaaaa'
            )
            form = EmployeeForm(params)
            form.is_valid()
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )
            
        with self.subTest('7文字以上だとエラーになる'):
            params = dict(
                first_name='サンプル太郎',
                first_name_kana='さんぷるたろう',
                chatwork_id='012345678'
            )
            form = EmployeeForm(params)
            form.is_valid()
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )

        with self.subTest('7文字以内だとエラーになる'):
            params = dict(
                first_name='サンプル太郎',
                first_name_kana='さんぷるたろう',
                chatwork_id='012345'
            )
            form = EmployeeForm(params)
            form.is_valid()
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )


form.errorsが何者かみてみると・・・

print(type(form.errors))  # django.forms.utils.ErrorDictが出力される
print(type(form.errors['name_kana']))  # django.forms.utils.ErrorListが出力される

print(form.errors)  # <ul class="errorlist"><li>last_name<ul class="errorlist"><li>この項目は必須です。</li></ul></li><li>name_kana<ul class="errorlist"><li>ひらがなで書いてください</li></ul></li></ul>
print(form.errors['name_kana'])  # <ul class="errorlist"><li>ひらがなで書いてください</li></ul>

form.errorsはErrorDictというDictを継承したオブジェクトで

form.errorsはErrorListというListを継承したオブジェクトのようです。


ErrorDictのkeyはそのフォームの各フィールドになっているそうです。 ErrorListは各フィールドのエラーの表示方法を決めてるっぽいですね。

print()でulタグが出力されたのは、__str__の処理の中がas_ulだからかと謎が解けました。

https://github.com/django/django/blob/master/django/forms/utils.py


少しテストから話が逸れますが validatorsを使わずclean_field使用してもテストは同じ結果で通りました。

class EmployeeForm(forms.Form):
    name = forms.CharField(
        max_length=225,
        label='名前',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    name_kana = forms.CharField(
        max_length=225,
        label='名前(かな)',
        required=False,
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    chatwork_id = forms.CharField(
        label='チャットワークID',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )

    def clean_name_kana(self):
        pattern = r'^[ぁ-ん]+$'
        name_kana = self.cleaned_data['name_kana']
        if not re.fullmatch(pattern, name_kana):
            raise ValidationError('ひらがなで書いてください')
        return name_kana

    def clean_chatwork_id(self):
        pattern = r'^[0-9]+$'
        chatwork_id = self.cleaned_data['chatwork_id']
        if not re.fullmatch(pattern, chatwork_id) or len(chatwork_id) != 7:
            raise ValidationError('半角数字7文字で書いてください')
        return chatwork_id

ちなみに、validatorとclean_fieldの違いについてはこちらの記事をご参照ください。 validatorはそれぞれのフィールドに入力された値をチェックするけれどもフォーム自体の内容は確認しないっぽいですね。

cleanが通らない場合があると考えると、clean_fieldを使った方が無難そう

hateda.hatenadiary.jp

今回使用したコードまとめ

forms.py

import re

from django import forms
from django.core.exceptions import ValidationError


def is_hiragana(value):
    pattern = r'^[ぁ-ん]+$'
    if not re.fullmatch(pattern, value):
        raise ValidationError('ひらがなで書いてください')


def validate_chatwork_id(value):
    pattern = r'^[0-9]+$'
    if not re.fullmatch(pattern, value) or len(value) != 7:
        raise ValidationError('半角数字7文字で書いてください')


class EmployeeForm(forms.Form):
    name = forms.CharField(
        max_length=225,
        label='名前',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    name_kana = forms.CharField(
        max_length=225,
        label='名前(かな)',
        validators=[is_hiragana],
        required=False,
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    chatwork_id = forms.CharField(
        label='チャットワークID',
        validators=[validate_chatwork_id],
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )

"""こっちでも良い

class EmployeeForm(forms.Form):
    name = forms.CharField(
        max_length=225,
        label='名前',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    name_kana = forms.CharField(
        max_length=225,
        label='名前(かな)',
        required=False,
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    chatwork_id = forms.CharField(
        label='チャットワークID',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    
    def clean_name_kana(self):
        pattern = r'^[ぁ-ん]+$'
        name_kana = self.cleaned_data['name_kana']
        if not re.fullmatch(pattern, name_kana):
            raise ValidationError('ひらがなで書いてください')
        return name_kana
    
    def clean_chatwork_id(self):
        pattern = r'^[0-9]+$'
        chatwork_id = self.cleaned_data['chatwork_id']
        if not re.fullmatch(pattern, chatwork_id) or len(chatwork_id) != 7:
            raise ValidationError('半角数字7文字で書いてください')
        return chatwork_id

 """


test_forms.py(ちょっとテスト増やして整えた)

from django.core.exceptions import ValidationError
from django.test import TestCase
from .forms import EmployeeForm


class TestEmployeeForm(TestCase):
    @classmethod
    def setUpClass(cls):
        pass

    def test_valid_form(self):
        params = dict(
            name='サンプル太郎',
            name_kana='さんぷるたろう',
            chatwork_id='0123456'
        )
        form = EmployeeForm(params)
        self.assertTrue(form.is_valid())

    def test_invalid_form_not_hiragana(self):
        params = dict(
            first_name='サンプル太郎',
            first_name_kana='サンプルタロウ',
            chatwork_id='0123456'
        )
        form = EmployeeForm(params)
        form.is_valid()

        with self.subTest('フォームのバリーデーションで妥当ではないと返される'):
            self.assertFalse(form.is_valid())

        with self.subTest('ひらがなで書けというエラーになる'):
            self.assertTrue(
                'ひらがなで書いてください' in form.errors['name_kana']
            )

        self.assertTrue(
            'ひらがなで書いてください' in form.errors['name_kana']
        )

    def test_invalid_form_chatwork_id_has_charactor(self):
        params = dict(
            name='サンプル太郎',
            name_kana='さんぷるたろう',
            chatwork_id='012aaaaa'
        )
        form = EmployeeForm(params)
        form.is_valid()
        with self.subTest('半角数字以外を含むとフォームのバリーデーションで妥当ではないと返される'):
            self.assertFalse(form.is_valid())

        with self.subTest('半角数字以外を含むと半角数字7文字で書けというエラーになる'):
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )

    def test_invalid_form_chatwork_id_is_over_7(self):
        params = dict(
            name='サンプル太郎',
            name_kana='さんぷるたろう',
            chatwork_id='012345678'
        )
        form = EmpForm(params)
        form.is_valid()

        with self.subTest('7文字以上だとフォームのバリーデーションで妥当ではないと返される'):
            self.assertFalse(form.is_valid())

        with self.subTest('7文字以上だと半角数字7文字で書けというエラーになる'):
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )

    def test_invalid_form_chatwork_id_is_less_than_7(self):
        params = dict(
            name='サンプル太郎',
            name_kana='さんぷるたろう',
            chatwork_id='012345'
        )
        form = EmpForm(params)
        form.is_valid()
        with self.subTest('7文字以内だとフォームのバリーデーションで妥当ではないと返される'):
            self.assertFalse(form.is_valid())

        with self.subTest('7文字以内だと半角数字7文字で書けというエラーになる'):
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )

    @classmethod
    def tearDownClass(cls):
        pass

DjangoCongress2019に参加しました

初DjangoConでした!

とても刺激になったので拝聴したトークを自分なりにアウトプットしようと思います。

なお、DjangoCongressJPのHPは以下になります。(TimeTableも載っています)

djangocongress.jp


私が拝聴したのは下記のトークです!

  1. Djangoで静的ファイルとうまくやる話

  2. How to build and deploy a flexible React/Django hybrid application

  3. Djangoでのメール送信 - 設定からテストまで

  4. Djangoアプリのデプロイに関するプラクティス

  5. Authorization in Django

  6. DjangoによるWebエンジニア育成への道


Djangoで静的ファイルとうまくやる話

tell-k.github.io

staticファイルの扱いはなんとなくやっているところがあったので 丁寧に説明してくださってとても為になりました。

Django特有の話だけでなく

「静的」と「動的」の違いやNginxの設定の話もしてくださりました。

Django始めたときに出会いたかった・・・笑

また、AmazonS3を使うと静的ファイルの同期作業から解放されるとのことなので使いたさが増しました!


How to build and deploy a flexible React/Django hybrid application

slides.com

一部のページではDjangoテンプレートを使い、複雑なUIのページにはReactを使うというお話。

propsをrenderを使って返すという方法は聞いたことなかったので新鮮でした!

今までTemplateしか使っていなかったプロジェクトにReactやVueを導入する際に参考にしたいとも思いました。


Djangoでのメール送信 - 設定からテストまで

speakerdeck.com

Djangoのメール送信機能の仕組みのお話。

基本的な機能の説明はさながら、メール送信のユニットテストの方法やログ出力の方法までご紹介くださりました。

恥ずかしながら、実際にメール送信して挙動確認していたので

ユニットテストでメール文や添付ファイルの確認をする方法は早速仕事で使いたいと思います!

また、運用に備えてログの設定もしたい・・・!


Djangoアプリのデプロイに関するプラクティス

www.slideshare.net

DjangoというよりWeb全般という感じでしたが、構成管理から監視まで幅広くお話してくださり、貴重な資料だなと思いました。 すごすぎて途中から理解できない部分があったので、経験を重ねてからもう一度読み返したいです。

知識と実践を重ねの上に重ねた人なんだな、と思いました。


Authorization in Django

GitPitch Slide Deck

Djangoで複雑な認証を実装するときに役に立つライブラリ「djnago-keeper」の紹介でした。 (グループAの人は○○が出来き、グループBの人は○○と△△が出来る、みたいなやつ)

なんと、発表者であるhirokikyさんご自信が作ったとのこと・・・! すごい・・・

認証対象のユーザーモデルに__acl__メソッドを書き、その中に複雑なロジックを書けば

Viewデコレータをシンプルに保てるというのは便利と思いました。また、変更も__acl__のみで済むのも魅力的でした。


DjangoによるWebエンジニア育成への道

speakerdeck.com

エンジニアリング要素たっぷりというより、とある会社のストーリーという発表でした。

売上至上主義な開発で会社が行き詰まったところから、Pythonに強く付加価値のある開発が出来る会社に成長するまでに歩んだ道という内容です。

個人的に現状行き詰まっているところに重なり響くものが多く、とても勇気をもらいました。

懇親会で発表者のnakazawaさんとお話しましたが、気さくで良い方でした。たくさんアドバイスいただきました。ありがとうございます・・・!


たくさん刺激をもらった一日でした。Djangoについて知らないことを学ぶだけでなく、様々な人とお話してつながりを持てた貴重な機会でした。

初めてあったのに相談に乗ってくださった方もいて、とても感謝しています。

スタッフの皆様、素晴らしい機会を提供してくださりありがとうございました。

来年はトークorLTで登壇できるようにがんばるぞー!

【Django】JsonResponseでのテストの仕方(GET/POST)

久々の投稿となってしまいました(^^;

今回使用したバージョン

* Python 3.7.1
* Django 2.1.7


今回サンプルとしてテストしたいメソッドはこちらのView関数

views.py

import json

from django.views.decorators.http import require_POST, require_GET
from django.http import JsonResponse

from .models import Item


@require_GET
def get_item(request, id: int) -> JsonResponse:
    """商品情報を取得するAPIです。

    :param request:
    :param id: ItemモデルのPK
    :return: JsonResponse
    """

    try:
        item = Item.objects.get(pk=id)
    except Item.DoesNotExist:
        return JsonResponse(
            data={},
            status=404
        )
    data = {
        'name': item.name,
        'price': item.price,
        'type': item.get_type_display()
    }
    return JsonResponse(
        data=data
    )


@require_POST
def create_item(request) -> JsonResponse:
    """商品情報を登録するAPIです。

    :param request:
    :return:
    """
    request_data = json.loads(request.body)
    Item.objects.create(
        name=request_data.get('name'),
        price=request_data.get('price'),
        type=request_data.get('type'),
        other_type=request_data.get('other_type')
    )

    data = {
        'message': '商品を登録しました'
    }
    return JsonResponse(
        data=data
    )


付随する情報たち

urls.py

from django.urls import path

from . import views

app_name = 'items'

urlpatterns = [
    path('<int:id>', views.get_item, name='get'),
    path('create', views.create_item, name='create'),
]

models.py

from django.db import models

from django.core.validators import MinValueValidator


class Item(models.Model):
    """商品モデル

    """

    # 品種
    TYPE_CHOICES = (
        (0, 'コーヒー'),
        (1, '紅茶'),
        (2, 'ココア'),
        (3, 'タピオカ'),
        (4, 'その他')
    )
    name = models.CharField(max_length=225)
    price = models.IntegerField(validators=[MinValueValidator(0)])
    type = models.SmallIntegerField(choices=TYPE_CHOICES)
    other_type = models.CharField(max_length=225, blank=True, null=True)  # typeがその他の場合

    class Meta:
        db_table = 'items'


テストしたい事項としては下記

get_item

  • パラメータで指定したIDの商品情報が返される

  • 存在しないIDがパラメータになったら404エラーとなる

  • 想定しないメソッドでリクエストが送られたら405エラーとなる

create_item

  • リクエストで送った内容の商品が登録される

  • 想定しないメソッドでリクエストが送られたら405エラーとなる


まず、get_itemのテストを。

商品情報が返されるか確認します。

JsonResponseの中身(dataの部分)が正しいか確認したいということで探していると dataの部分はresponse.contentとなっていることがわかりました。

>>> from django.http import JsonResponse
>>> response = JsonResponse({'foo': 'bar'})
>>> response.content
b'{"foo": "bar"}'

docs.djangoproject.com

しかし、contentはbytes型なのでそのままではテストできません。

調べたら、json.loads()によってcontentをJSON化して内容をテストしている事例がありました。

medium.com

結果、get_itemによって返される内容を確認するテストは下記のようになりました。

tests.py

import json

from django.test import TestCase, Client
from django.urls import reverse

from .models import Item


class GetItem(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.item = Item.objects.create(
            name='ゲイシャ',
            price=500,
            type=0
        )
        cls.client = Client()

    def test_get_item(self):
        response = self.client.get(
            path=reverse('items:get', kwargs={'id': 1})
        )

  # response.contentをJSON化する
        content = json.loads(response.content)
        with self.subTest('商品名が返される'):
            self.assertEqual(
                'ゲイシャ',
                content['name']
            )

        with self.subTest('値段が返される'):
            self.assertEqual(
                500,
                content['price']
            )

        with self.subTest('品種が返される'):
            self.assertEqual(
                'コーヒー',
                content['type']
            )


また、下記でもよかったようです。

content = response.json()

docs.djangoproject.com


続きましては、get_itemの異常系のテストです。

意図したステータスコードが返されるか確認します。

django.test.Clientによって作成されたdjango.test.Responseオブジェクトはアトリビュートstatus_codeにステータスコードを保持します。

response.status_codeでそのレスポンスのステータスコードがわかります。

docs.djangoproject.com

    def test_not_existing_item(self):
        response = self.client.get(
            path=reverse('items:get', kwargs={'id': 2})
        )
        content = json.loads(response.content)

        with self.subTest('コンテントは空である'):
            self.assertFalse(
                content
            )

        with self.subTest('404エラーで返される'):
            self.assertEqual(
                404,
                response.status_code
            )

    def test_request_post(self):
        response = self.client.post(
            path=reverse('items:get', kwargs={'id': 2}),
            data={}
        )
        with self.subTest('405エラーで返される'):
            self.assertEqual(
                405,
                response.status_code
            )


続いてcreate_itemのテスト。

下記でいけるかな〜と思いましたが、だめでした(^^;

def test_create_item(self):
        response = self.client.post(
            path=reverse('items:create'),
            data={
                'name': 'ハワイコナ',
                'price': 800,
                'type': 0
            },
        )

どうやら、content_type="application/json"がないと正しいcontent_typeでリクエストが送られなかった模様。

stackoverflow.com

デフォルトのcontent_typeがMULTIPART_CONTENTなので、そりゃ何も指定しないでうまくいくはずがありませんでした。

docs.djangoproject.com

結果、create_itemのテストはこうなりました。

    def test_create_item(self):
        response = self.client.post(
            path=reverse('items:create'),
            data={
                'name': 'ハワイコナ',
                'price': 800,
                'type': 0
            },
            content_type='application/json'
        )
        content = json.loads(response.content)
        with self.subTest('商品が登録される'):
            self.assertTrue(
                Item.objects.filter(name='ハワイコナ', price=800, type=0).exists()
            )

        with self.subTest('登録完了のメッセージが返される'):
            self.assertEqual(
                '商品を登録しました',
                content['message']
            )

    def test_request_get(self):
        response = self.client.get(
            path=reverse('items:create')
        )
        with self.subTest('405エラーで返される'):
            self.assertEqual(
                405,
                response.status_code
            )

2つの関数のテストをまとめるとこうなります。

tests.py

import json

from django.test import TestCase, Client
from django.urls import reverse

from .models import Item


class GetItem(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.item = Item.objects.create(
            name='ゲイシャ',
            price=500,
            type=0
        )
        cls.client = Client()

    def test_get_item(self):
        response = self.client.get(
            path=reverse('items:get', kwargs={'id': 1})
        )
        content = json.loads(response.content)
        with self.subTest('商品名が返される'):
            self.assertEqual(
                'ゲイシャ',
                content['name']
            )

        with self.subTest('値段が返される'):
            self.assertEqual(
                500,
                content['price']
            )

        with self.subTest('品種が返される'):
            self.assertEqual(
                'コーヒー',
                content['type']
            )

    def test_not_existing_item(self):
        response = self.client.get(
            path=reverse('items:get', kwargs={'id': 2})
        )
        content = json.loads(response.content)

        with self.subTest('コンテントは空である'):
            self.assertFalse(
                content
            )

        with self.subTest('404エラーで返される'):
            self.assertEqual(
                404,
                response.status_code
            )

    def test_request_post(self):
        response = self.client.post(
            path=reverse('items:get', kwargs={'id': 2}),
            data={}
        )
        with self.subTest('405エラーで返される'):
            self.assertEqual(
                405,
                response.status_code
            )

    @classmethod
    def tearDownClass(cls):
        pass


class CreateItem(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.client = Client()

    def test_create_item(self):
        response = self.client.post(
            path=reverse('items:create'),
            data={
                'name': 'ハワイコナ',
                'price': 800,
                'type': 0
            },
            content_type='application/json'
        )
        content = json.loads(response.content)
        with self.subTest('商品が登録される'):
            self.assertTrue(
                Item.objects.filter(name='ハワイコナ', price=800, type=0).exists()
            )

        with self.subTest('登録完了のメッセージが返される'):
            self.assertEqual(
                '商品を登録しました',
                content['message']
            )

    def test_request_get(self):
        response = self.client.get(
            path=reverse('items:create')
        )
        with self.subTest('405エラーで返される'):
            self.assertEqual(
                405,
                response.status_code
            )

    @classmethod
    def tearDownClass(cls):
        pass


まあ、DRFでやれよという話ですが、そちらはおいおい出来たらと思います!

【非公式翻訳】ForeignKey in Django公式ドキュメント

Django公式ドキュメントのForeignKeyの日本語訳です。

docs.djangoproject.com

間違いがありましたら、コメント欄にてご指摘お願いします!


ForeignKey

class ForeignKey(to, on_delete, **options) [ソース]

多対一のリレーションです。2つの位置引数を必要とします。すなわち、そのモデルに紐付いているクラスとon_deleteオプションです。

再帰的なリレーションを築くために(別の表現をすると、多対一のリレーションをもつオブジェクトをつくるために)、models.ForeignKey('self', on_delete=models.CASCADE)を利用します。

未定義のモデルに紐づくリレーションを築く必要がある場合、モデルオブジェクト自体を使うのではなく、モデルの名前を使うことができます。

from django.db import models

class Car(models.Model):
    manufacturer = models.ForeignKey(
        'Manufacturer',
        on_delete=models.CASCADE,
    )
    # ...

class Manufacturer(models.Model):
    # ...
    pass


具体的なモデルとしてサブクラス化され、抽象モデルのapp_modelと関連しなくなった場合、抽象モデルを利用したこの方法で定義されるリレーションは決められます。

products/models.py

from django.db import models

class AbstractCar(models.Model):
    manufacturer = models.ForeignKey('Manufacturer', on_delete=models.CASCADE)

    class Meta:
        abstract = True


production/models.py

from django.db import models
from products.models import AbstractCar

class Manufacturer(models.Model):
    pass

class Car(AbstractCar):
    pass

# Car.manufacturer will point to `production.Manufacturer` here.


他のアプリケーションで定義されたモデルを参照するには、フルのアプリケーションラベルをつけてモデルを明示的に特定する必要があります。例えば、上記のManufacturerモデルが、productionという他のアプリケーションで定義される場合、下記のようにします。

class Car(models.Model):
    manufacturer = models.ForeignKey(
        'production.Manufacturer',
        on_delete=models.CASCADE,
    )


遅延評価と呼ばれる※1この参照方法は、2つのアプリケーション間で回帰する依存関係?を解消します。

データベースのインデックスは自動的にForeignKeyに作成されます。db_indexFalseにすることでこれを無効にすることができます。 一貫性のためにJOINではなくForeignKeyを作っている場合や、部分インデックスまたは複合インデックスのような代替的なインデックスを作成している場合、インデックスの重複を避けたくなるでしょう。


データベースでの表記方法

Djangoは裏で、データベースのカラム名を作る際にフィールド名の後ろに"_id"をつけます。上記の例では、Carモデルはmanufacturer_idカラムを持ちます。(db_columnを指定することで、明示的にこれを変更することができます。)しかし、カスタムSQLを書かない限り、コードでデータベースのカラム名を扱うべきではありません。常にモデルのオブジェクトのフィールド名を扱うべきです。


引数

ForeignKeyは、リレーションがどのように機能するかの詳細を定義した他の引数を受け付けます。

ForeignKey.on_delete

ForeingKeyで参照されているオブジェクトが削除された時、Djangoon_delete引数によって指定されたSQL制約をエミュレートします。例えば、もしNULLにできるForeignKeyがあり、参照されたオブジェクトが削除された際にNullをセットしたいとします:

user = models.ForeignKey(
    User,
    models.SET_NULL,
    blank=True,
    null=True,
)

on_deleteは、データベースにSQL制約を作成しません。データベースレベルのカスケードのオプションは後々実装される予定です

on_deleteの設定可能な値は、django.db.modelsで確認できます:


カスケードの削除。DjangoはON DELETE CASCADESQL制約の振る舞いをエミュレートし、ForeignKeyを含むオブジェクトを削除します。

関連したモデルにModel.delete()は呼ばれませんが、削除されるすべてのオブジェクトにpre_deletepost_deleteの信号が送られます。


django.db.IntegrityErrorのサブクラスであるProtectedErrorを発生させることで、参照されたオブジェクトが削除されるのを防ぎます。


ForeignKeyをNullにセットします; これはnullTrueである場合のみ可能です。


ForeinKeyをデフォルト値にセットします; ForeinKeyのデフォルトを必ずセットしなくてはなりません。

ForeinKey、もしくはcallableが渡された場合はそれを呼び出した結果をSET()にに渡される値にセットします。ほとんどの場合、callableを渡すのは、models.pyをインポートした時にクエリーを実行することを避けるために必要です。

from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models

def get_sentinel_user():
    return get_user_model().objects.get_or_create(username='deleted')[0]

class MyModel(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET(get_sentinel_user),
    )


何も実行しません。もし、データベースバックエンドが参照整合性を強要する場合、ON DELETE制約をデータベースのフィールドに自動的に追加しない限り、 IntegrityErrorを引き起こします。


ForeignKey.limit_choices_to

ModelFormかadminを利用してこのフィールドがレンダーされている時に、選択可能な選択肢を制限します。(デフォルトでは、クエリーセットのすべてのオブジェクトを選択することができます。)辞書型、Qオブジェクト、辞書型かQオブジェクトを返すcallableが利用可能です。

staff_member = models.ForeignKey(
    User,
    on_delete=models.CASCADE,
    limit_choices_to={'is_staff': True},
)


例えば上記は、is_staff=TrueであるUsersしか選べないようなModelFormのフィールドを作成します。これはDjango adminに役立ちます。

例えば、下記のように、 日付の選択肢を制限するためにPythonのdatetimeモジュールと接続して利用する時にcallableフォームも役立ちます。

def limit_pub_date_choices():
    return {'pub_date__lte': datetime.date.utcnow()}

limit_choices_to = limit_pub_date_choices


limit_choices_toQオブジェクトである、もしくはQオブジェクトを返す場合(Qオブジェクトは、複雑なクエリーに役立ちます)、limit_choices_toがModelAdminraw_id_fieldsのリストに入っていない時にadminで利用できる選択肢に影響します。


注釈

limit_choices_toがcallableに利用されている場合、新しいフォームが初期化されるたびに呼び出されます。また、例えばmanageコマンドやadminによってモデルが有効にされた時にも呼び出されます。adminは何回も、多様なエッジケースにおいてフォームの入力を有効化するためにクエリーセットを構築します。そのため、callableが何回も呼び出される可能性があります。


ForeignKey.related_name

あるオブジェクトと関連しているオブジェクトとの関係に利用する名前です。また、related_query_name(ターゲットとなるモデルからの逆引きされる名前に利用するための名前)に使うためのデフォルト値でもあります。)全ての説明と例を見るには、「リレーションシップ "反対向き” を理解する」をご参照ください。抽象モデルとの関係を定義する時はこの値をセットする必要があることを心に留めておいてください。それによって、いくつかの特別なシンタックスを利用できます。

Django裏側での関係を生成して欲しくない場合、related_nameに'+'や'+'で終わる名前をつけてください。例えば、これによってUserモデルがこのモデルと裏側での関係を持たないようにします。

user = models.ForeignKey(
    User,
    on_delete=models.CASCADE,
    related_name='+',
)


ForeignKey.related_query_name

ターゲットとなるモデルの逆引きに利用するための名前です。設定すると、related_nameまたはdefault_related_nameのデフォルトとなります。もしくはそのモデル名のデフォルトとなります。

# Declare the ForeignKey with related_query_name
class Tag(models.Model):
    article = models.ForeignKey(
        Article,
        on_delete=models.CASCADE,
        related_name="tags",
        related_query_name="tag",
    )
    name = models.CharField(max_length=255)

# That's now the name of the reverse filter
Article.objects.filter(tag__name="important")


related_nameのように、related_query_name特別なシンタックスを通してapp labelとクラスの補間をサポートします。


ForeignKey.to_field

関連しているオブジェクトのフィールドです。デフォルトでは、Djangoは関連したオブジェクトのプライマリーキーを利用します。異なるフィールドを参照したい場合、そのフィールドをunique=Trueにしなければなりません。


ForeignKey.db_constraint

そのForeignKeyの制約をデータベースに作るかどうかの制御をします。デフォルトは、Trueですが、ほぼあなたがしたいことでしょう。これをFalseにすることはデータの一貫性からして良くないです。ここで、これを行いたい場合のシナリオを用意しました。

  • 妥当ではないレガシーなデータがある場合

  • データベースを破損した場合

Falseにセットした場合、存在しない関連したオブジェクトにアクセスしたらDoesNotExistを例外として吐き出します。


ForeignKey.swappable

ForeignKeyスワップできるモデルを指している時のマイグレーションフレームワークの動きを制御します。True(デフォルト)の場合、ForeignKeysettings.AUTH_USER_MODEL(もしくは、他のスワップできるモデルに関するsettings)の現在の値と整合するモデルを指している場合、モデルに直接する保管するのではなく、関係はsettingsの参照を利用するマイグレーションに保管されます。

例えばモデルが常にスワップインされたモデル(例えば、カスタムユーザーモデルのために特別に設計されたプロフィールモデル)を指すと確信している場合のみ、Falseをオーバーライドしたくなるでしょう。

Falseにすることは、スワップできるモデルがスワップアウトされても参照できるという意味ではありません。Falseはただ、ForeignKeyと作られたマイグレーションはあなたが特定したモデルを常に参照することを意味します(例えば、あなたがサポートしていないUserモデルを実行しようとすると落ちます。)

不安な場合、デフォルトであるTrueのままにしておいてください。


※1 Django特有の他の言い方があるように思えるので合ってる自信がありません汗

【非公式翻訳】Google API Client Libraries > Python

公式の日本語版がなかったので、備忘録的に・・・

※2017/02/17が最終更新の記事となります。 ※表現するのが難しかったので図中の語句の訳は書きませんでした。 ※わかりづらい箇所や間違えている箇所がありましたら、コメント欄にてご指摘お願いします。

developers.google.com


OAuth 2.0

このドキュメントはOAuth 2.0を説明してます。OAuth 2.0を利用する際に、どのようにクライアントIDを取得するのか、どのようにPythonGoogle API クライアントのライブラリと利用するかを説明します。


OAuth 2.0とは

OAuth 2.0は、Google APIに利用される認証プロトコルです。このライブラリーのドキュメントの認証に関するページにようやくされています。また、他にも良い参照ページがあります。

プロトコルは複雑な問題を解決するため、理解するのが難しいかと存じます。

このプレゼンはプロトコルの重要な概念と、各ステップでライブラリをどのように利用するかを説明します。


ーーーーー

※プレゼンテーションスライドの翻訳。

(1枚目) OAuth 2.0とPythonのためのGoogle API クライアント

(2枚目) OAuth 2.0では、プロトコルが少々複雑です。

(3枚目) OAuth 2.0は、トリッキーな問題を解決しようとしています。

(4枚目) あなた(開発者)がアプリケーションをビルドしようとする際、

(5枚目) もしあなたのユーザーが

(6枚目) あなたのアプリケーションが機能するために必要な、他のサービス内にあるデータを持っているとすると

(7枚目) ー例えば、彼らのタスクリストや写真のような

(8枚目) どのようにそのデータを取得すればよいでしょうか?

(9枚目) あなたはユーザーに名前とパスワードを要求することができます。

(10枚目) しかし、そうしますと、ユーザーは そのサービス内にある彼らのデータすべてに対するアクセス権を あなたのアプリケーションに提供してしまいます。 それは安全ではありません。それはしないでください。

(11枚目) 名前とパスワードは彼らの電子上に保管されているすべての情報のキーのようなもので、それらをユーザーに要求すべきではありません。

(12枚目) 我々が本当に欲しているものは、APIの限られたデータへのみアクセスを認証する特別なキーです。

(13枚目) 特別なキーは、名前もパスワードも使わずに、アプリケーションを利用できるよう 要求することができます。

(14枚目) その仕組みが機能するためには、API・ユーザー・アプリケーションの全員が、「自分はーーーである」と述べている通りの者であるかをそれぞれ確かめなければなりません。

(15枚目) それが、この複雑さの由来となります。

(16枚目) 実は先程の説明よりももう少し複雑です。なぜなら、その特別なキーは

(17枚目) データをセキュアな状態に保つために何回も変化するからです。

(18枚目) ようやく OAuth 2.0がどのようなものかが分かりました。それはPythonGoogle API クライアントではどのように機能するのでしょうか?

(19枚目) そのキーはCredentialsオブジェクトの中にあります

(20枚目) Credentialsを得るためにはFlowオブジェクトを通さなければなりません。

(21枚目) 最終的に、キーは何回も変化するため、キーを保持し、取り出すためのStorageオブジェクトがあります。

(22枚目) セットアップし、Flowオブジェクトを実行することでCredentialsオブジェクトを生成し、Storageオブジェクトに生成したCredentialsオブジェクトを保管します。

(23枚目) 後々、キーが必要になった時、Storageオブジェクトから取り出し、利用することができます。

(24枚目) この画像の図よりもシンプルだと思っていただけると嬉しいです。

(25枚目) 実際のコードを見てみましょう。

(26枚目) まず、Flowオブジェクトを生成します。

(27枚目) Google APIでは、あなたのアプリケーション用のクライアントIDと秘密鍵を作成するために

http://code.google.com/apis/console

にアクセスする必要があります。

(28枚目) Flowを立ち上げます。

(29枚目) Flowが終了した時に、Credentialsを取得します。それをStorageに保管します。

(30枚目) Credentialsを利用するために、Storageから取り出し、httplib2.Http()オブジェクトCredentialsを適用します。

(31枚目) httpで作られたあらゆるHTTPリクエストは、これらのCredentialsによって認証されます。

(32枚目) プラットフォームごとにStorageクラスがあります。

(33枚目) より簡単にするために、Appエンジンを助ける方法もあります。

(34枚目) デコレーターを載せると、デコレーターによってFlows, Storage, Credentialsが利用されます。

(35枚目) oauth_requiredは最もシンプルに利用できるインターフェースある一方、oauth_awareが推奨されているインターフェースです。

(36枚目) ユーザーのデータへのアクセスをリクエストしている理由の説明をユーザーに弁明するために、linkを設置することができます。

(37枚目) 以上が概要となります。より詳細な情報は下記をご参照ください。

より詳しい情報がWikiにあります。

https://developers.google.com/api-client-library/python/guide/aaa_oauth

説明したクラスに関するPyDocはこちら

OAuth2WebServerFlow

Credentials

StorageByKeyName

OAuth2Decorator

※上記4つにアクセスしたところ、404エラーになってしまいました。

ーーーーー


クライアントIDと秘密鍵を取得する

Google APIs ConsoleのAPI Access paneでクライアントIDと秘密鍵を取得できます。異なる種類のクライアントIDがあるため、あなたのアプリケーションにとって正しい種類のものを取得するに気をつけましょう。

  • WebアプリケーションのクライアントID
  • インストールアプリケーションのクライアントID
  • サービスアカウントのクライアントID


注意: 秘密鍵が外にもれないようにしてください。もし他の誰かがあなたの秘密鍵を取得したら、あなたのクォータを消費し、Google API Consoleプロジェクトの変更を突破し、ユーザーのデータにアクセスするようリクエストを送る可能性があります。


oauth2clientライブラリ

oauth2clientライブラリはPythonGoogle API Client ライブラリに含まれています。それは、APIを呼ぶのに必要なOAuth 2.0のプロトコルのすべてのステップを扱います。OAuth 2.0ライブラリのみ必要である場合、分離したパッケージとして利用することも可能です。下記の段落では、このライブラリで重要なモジュール、クラス、機能を説明します。


Flows

Flowクラスの目的は、あなたのアプリケーションがユーザーのデータにアクセスすることを認証するための認証情報を得ることです。ユーザーがアクセスを認証するには、OAuth 2.0のステップはあなたのアプリケーションに、潜在的に複数回ブラウザにリダイレクトすることを要求します。Flowオブジェクトには、あなたのアプリケーションがこれらのステップを踏んで認証情報を得ることを助ける機能があります。Flowオブジェクトは一時的なものであり、Credentialsを生成すると捨てられる可能性があります。ただし、それらは保存(pickle)されたり保管されることもできます。


★メモ:プラットフォーム特有のFlowのための「Google App Engineを利用する 」「Djangoを利用する」のページをご覧ください。


flow_from_clientsecrets()

oauth2client.client.flow_from_clientsecrets() メソッドは、 client_secrets.jsonファイルからFlowオブジェクトを生成します。この形式のJSONファイルはクライアントID、秘密鍵、OAuth 2.0のパラメータを保管しています。

下記は、Flowオブジェクトを作成するためにどのようにflow_from_clientsecrets() を利用するかを示します。

from oauth2client.client import flow_from_clientsecrets
...
flow = flow_from_clientsecrets('path_to_directory/client_secrets.json',
                               scope='https://www.googleapis.com/auth/calendar',
                               redirect_uri='http://example.com/auth_return')


OAuth2WebServerFlow

名前にそぐわず、oauth2client.client.OAuth2WebServerFlow クラスはイントールアプリケーションにもWebアプリケーションにも利用できます。OAuth2WebServerFlowオブジェクトは、クライアントID・秘密鍵・そのコンストラクタへのスコープをパスすることで生成されます。: あなたはredirect_uriパラメータでコンストラクタを提供します。これはあなたのアプリケーションが扱っているURIでなければなりません。

from oauth2client.client import OAuth2WebServerFlow
...
flow = OAuth2WebServerFlow(client_id='your_client_id',
                           client_secret='your_client_secret',
                           scope='https://www.googleapis.com/auth/calendar',
                           redirect_uri='http://example.com/auth_return')


step1_get_authorize_url()

Flowクラスのstep1_get_authorize_url() ファンクションは認証サーバのURIを生成するために利用されます。 認証サーバのURIをもつと、ユーザーがそこにリダイレクトします。下記は、このファンクションを呼び出す一例です。

auth_uri = flow.step1_get_authorize_url()
# Redirect the user to auth_uri on your platform.

ユーザーが以前にあなたのアプリケーションへのアクセスが認証されていたら、認証サーバはただちにredirect_uriにリダイレクトします。ユーザーがまだアクセスが認証されていなかったら、認証サーバはあなたのアプリケーションにアクセスしても良いか訪ねます。アクセスを認証したら、下記のようなcodeクエリの文字列パラメータをもったredirect_uriにリダイレクトします。

http://example.com/auth_return/?code=kACAH-1Ng1MImB...AA7acjdY9pTD9M

アクセスを拒否したら、下記のようなエラークエリの文字列パラメータをもったredirect_uriにリダイレクトします。

http://example.com/auth_return/?error=access_denied


step2_exchange()

Flowクラスのstep2_exchange() ファンクションは、Credentialsオブジェクトの認証コードを交換します。このファンクションに認証サーバのリダイレクトで得たコードを渡してください。

credentials = flow.step2_exchange(code)


Credentials

Credentialsオブジェクトは、単一のユーザーデータへのアクセスを認証するリフレッシュトークンとアクセストークンを保持します。これらのオブジェクトは、アクセスを認証するhttplib2.Httpオブジェクトに適用されます。一度だけ適用すればよく、それらは保管されることができます。この段落ではCredentialsオブジェクトを 生成・利用するための様々なメソッドを説明します。

★メモ:プラットフォーム特有のFlowのための「Google App Engineを利用する 」「Djangoを利用する」のページをご覧ください。


OAuth2Credentials

oauth2client.client.OAuth2Credentials クラスは、ユーザーのデータへのアクセスを認証する、OAuth 2.0の認証情報をもっています。通常、コンストラクタを呼んでこのオブジェクトを生成しません。Flowオブジェクトが生成します。


ServiceAccountCredentials

oauth2client.service_account.ServiceAccountCredentialsクラスは、OAuth 2.0 Service Accountsでしか利用されません。エンドユーザーはサーバー間のAPIの呼び出しに関わりません。そのため、Flowオブジェクトなしで直接このオブジェクトを生成することができます。


AccessTokenCredentials

oauth2client.client.AccessTokenCredentialsクラスは他の方法によってアクセストークンを既に取得している時に利用されます。Flowオブジェクトなしでこのオブジェクトを生成できます。


authorize()

必要な認証ヘッダーをhttplib2.Httpインスタンスによって作成されたすべてのリクエストへに適用するには、 Credentialsクラスのauthorize()ファンクションを利用してください。:

import httplib2
...
http = httplib2.Http()
http = credentials.authorize(http)

一度httplib2.Httpが認証されたら、それは一般的にビルドファンクションに渡されます。

from apiclient.discovery import build
...
service = build('calendar', 'v3', http=http)


Storage

oauth2client.client.Storageオブジェクトは、Credentialsオブジェクトを保管し、取り出します。この段落では、Storageオブジェクトを生成・利用する様々なメソッドを説明します。


★メモ:プラットフォーム特有のFlowのための「Google App Engineを利用する 」「Djangoを利用する」のページをご覧ください。


file.Storage

oauth2client.file.Storageクラスは単一のCredentialsオブジェクトを保管し、取り出します。このクラスは複数のプロセスやスレッドが単一のストアをオペレーションできるようなロックをサポートします。下記は、どのようにファイルを開き、Credentialsをファイルに保管し、これらの認証情報を取得するのかを示します。

from oauth2client.file import Storage
...
storage = Storage('a_credentials_file')
storage.put(credentials)
...
credentials = storage.get()


multistore_file

oauth2client.contrib.multistore_file モジュールを利用すると、複数の認証情報を保管することができます。認証情報は下記によって識別されます。

  • クライアントID
  • ユーザー情報
  • スコープ


keyring_storage

oauth2client.contrib.keyring_storage モジュールは、パスワードマネージャーを利用できる場合、単一のCredentialsオブジェクトをパスワードマネージャーの中に保管させることができます。認証情報は下記によって識別されます。

  • クライアントアプリケーションの名前
  • ユーザーネーム
from oauth2client.contrib.keyring_storage import Storage
...
storage = Storage('application name', 'user name')
storage.put(credentials)
...
credentials = storage.get()


Command-line tools

oauth2client.tools.run_flow() ファンクションは、コマンドラインアプリケーションが認証情報を取得するために利用されます。Flow引数は、ユーザーのデフォルトのWebブラウザーにある認証サーバのページを開こうとします。認証サーバはユーザー にあなたのアプリケーションがユーザーのデータにアクセスしてよいか訪ねます。もしユーザーがアクセスを許可したら、run()ファンクションが新しい認証情報を返します。新しい認証情報はStorage引数に保管され、そのStorageオブジェクトに関連したフィアルをアップデートします。

oauth2client.tools.run_flow()ファンクションは、コマンドラインのフラグによって制御されます。Pythonの標準ライブラリのargparse モジュールは、あなたのプログラムの最初に初期化されなくてはなりません。argparseはPython 2.7以上に含まれており、それ以前のバージョンではseparate packageとして利用可能です。下記は、どのようにこのファンクションを利用するかを示します。

import argparse
from oauth2client import tools

parser = argparse.ArgumentParser(parents=[tools.argparser])
flags = parser.parse_args()
...
credentials = tools.run_flow(flow, storage, flags)


特に記載のない限り、このページのコンテンツは クリエイティブ・コモンズの表示 3.0 ライセンス により使用許諾されます。サンプル コードは Apache 2.0 ライセンス により使用許諾されます。詳しくは、Google のサイトに関するポリシーをご覧ください。JavaOracleアフィリエイトのトレードマークに登録されています。

最終更新日 2017年2月17日