DjangoのフォームをDjango shellでデバッグする

Djangoフレームワークには、ウェブ用のFormを作る機能があります。

フォームを使う | Django ドキュメント | Django

フォームを定義して実際にブラウザ上で表示するためには、ビューやテンプレートを用意する必要があるのですが、少しフォームを試したいだけであれば、毎回すべてを用意するのは少し手間です。

フォームクラスの動作を確認するだけであれば、Djangoのrequestオブジェクトには異存しないので、Django shell(manage.py shell)からでも手軽に試せます。

フォームを定義する

適当にmyappというDjangoアプリケーションを作成し、プロジェクトのsettings.LANGUAGEを 'ja' に変更してからフォームを作ってみます。

myapp/forms.py:

from django import forms

class ContactForm(forms.Form):
    name = forms.CharField(label="お名前", max_length=10)
    email = forms.EmailField(label="メールアドレス")
    content = forms.CharField(widget=forms.Textarea)

Django shellから試す

manage.py shell を起動して、定義しておいた ContactForm クラスをインポートします。

>>> from myapp.forms import ContactForm

これでContactFormクラスを試す準備ができました。

フォームのインスタンスを作る、メソッドを呼び出す

Djangoのフォームは引数を省略した場合、空のフォーム(新規入力相当)を生成します。

>>> form = ContactForm()
>>> form
<ContactForm bound=False, valid=Unknown, fields=(name;email;content)>
>>> form.as_p()  # pタグでフィールドをレンダリングしたHTMLを生成
'<p>\n    <label for="id_name">お名前:</label>\n    <input type="text" name="name" maxlength="10" required id="id_name">\n    \n    \n  </p>\n\n  \n  <p>\n    <label for="id_email">メールアドレス:</label>\n    <input type="email" name="email" maxlength="320" required id="id_email">\n    \n    \n  </p>\n\n  \n  <p>\n    <label for="id_content">Content:</label>\n    <textarea name="content" cols="40" rows="10" required id="id_content">\n</textarea>\n    \n    \n      \n    \n  </p>'

form.as_p() のようにして、formの各メソッドを試すことができます。

この例だと、pタグでフォームのHTMLを出力するメソッドを試しています。定義したフィールドに対応するHTMLの文字列が生成されていることを確認できます。

入力バリデーションを試す

Djangoのフォームでよく試したいのは、入力バリデーションだと思います。試していきましょう。

Djangoのフォームを使う際に、ビューでは request.POST のようなオブジェクトを引数で渡していることが多いですが、これは辞書ライクなオブジェクト(QueryDict)です。

実際、Djangoのフォームは入力値としてPythonの辞書を渡せば動かすことができる、シンプルな作りになっています。

formには空の辞書を渡した場合、bound=Trueの状態のフォームインスタンスが作られます。Unbound Form, Bound Formについては、ドキュメントを参照してください。

フォーム API | Django ドキュメント | Django

Bound Formの場合、errorsを参照すると、バリデーションが実行されるので、必須入力のチェックが実行されていることを確認できます。

>>> form = ContactForm({})
>>> form
<ContactForm bound=True, valid=Unknown, fields=(name;email;content)>
>>> form.errors
{'name': ['このフィールドは必須です。'], 'email': ['このフィールドは必須です。'], 'content': ['このフィールドは必須です。']}

nameフィールドの初期値だけを与えてテストしてみましょう。

>>> form = ContactForm({'name': 'おなまえおなまえおなまえ'})
>>> form.is_valid()
False
>>> form.errors
{'name': ['この値は 10 文字以下でなければなりません( 12 文字になっています)。'], 'email': ['このフィールドは必須です。'], 'content': ['このフィールドは必須です。']}

このように、Django shellで、フォームのバリデーションを試すことができます。

cleand_dataを試す

フォームの is_valid() がTrueを返す状態であれば、 cleaned_data プロパティでクリーニングしたフィールド毎のデータも取得できます。

フォームフィールドやウィジェットでデータ変換が発生するフォームの場合は、これもDjango shell上で試せるとデバッグがはかどると思います。

>>> form = ContactForm({'name': 'モンティ・パイソン', 'email': 'foo@example.com', 'content': 'お問い合わせ内容'})
>>> form.is_valid()
True
>>> form.cleaned_data
{'name': 'モンティ・パイソン', 'email': 'foo@example.com', 'content': 'お問い合わせ内容'}

IPythonのautoreload

フォームを頻繁に書き換えて試す場合、毎回shellを起動しなおして、インポートからやりなおすのは手間です。

IPythonのshellであれば、autoreloadという拡張を使って少し楽をできるかもしれません。

autoreload — IPython 3.2.1 documentation

IPythonをインストールした状態で、 manage.py shell を起動すると、IPythonのシェルになります。

%load_ext autoreload で自動リロードの拡張をロードし、 %autoreload 2 で自動リロードのモードを変更します。

モード2は、「Reload all modules」となっていて、コード実行前に毎回すべてのモジュールがリロードされます。

In [1]: %load_ext autoreload

In [2]: %autoreload 2

In [3]: from myapp.forms import ContactForm

In [4]: form = ContactForm({})

In [5]: form
Out[5]: <ContactForm bound=True, valid=Unknown, fields=(name;email;content)>

# ここで、ソースコード内のcontentの行をコメントアウト

In [6]: form = ContactForm({})

In [7]: form
Out[7]: <ContactForm bound=True, valid=Unknown, fields=(name;email)>  # 自動でリロードされたクラスが使われている

プロジェクトが大きいと、毎回全てのモジュールをリロードするのは遅い可能性があるので、その場合はドキュメントにあるように %aimport を使うなどして、部分的にリロードするだけでもいいかもしれません。

まとめ

Django shellでフォームをインポートして試せることを紹介しました。

最終的にはユニットテストのコードを書いて、保守しやすい状態にするのがよいですが、試行錯誤する段階では、このようにshell上でデバッグする方法も手軽なので、知っておくとよいかなと思います。

PyCon APAC 2023へのポスターセッション参加とDjango利用状況アンケート結果

2023/10/27~10/28の2日間、PyCon APAC 2023に参加してきました。

今回はdjango-jaのポスターセッションをしました。

ポスターセッションは、所定の展示スペースに収まるサイズのポスターを作って、展示しておき、Breakの時間帯にはポスターの前に待機して、来訪者に説明する、というものです。

Djangoの利用状況のアンケートを先日行っていたので、結果のまとめと、django-jaの活動についてを記載したA0サイズのポスターを作成しました。

私は2日間、ポスターの前に長くいましたが、多くの人が来てくれて話すことができました。ありがとうございました。

ポスターの準備や当日の対応で、結構疲れてしまいましたが、やってよかったです。

過去にDjangoCongressJPで、海外からオンライン登壇してくれた方にも挨拶できました。

英語の対応が全然できないので、もっと頑張る必要があるなと思いました。

Django利用状況アンケート結果

ポスターのデータそのままですが、以下から閲覧できます。

https://djangoproject.jp/whouses/pyconapac2023-django-ja.pdf (PDFファイルです)

Djangoのテンプレートローダーを自作する

Djangoフレームワークのテンプレートエンジンは、『テンプレートをロードする処理』を自分で実装した処理に差し替えるための設定が用意されています。

django.template.loaders.base.Loader を継承したクラスを実装し、 settings.pyTEMPLATES を設定すると、処理を差し替えることができます。

※この記事の内容は、DjangoCongressJP 2023で発表した内容の一部をまとめなおしたものになります。

Djangoテンプレートエンジンを使いこなそう! - DjangoCongressJP 2023 @tokibito

アプリの用意

まずは、変更の比較のためにテンプレートを単純にレンダリングするビューを含むアプリを用意します。

python manage.py startapp myapp

myappのアプリをstartappで追加したら、 settings.pyINSTALLED_APPS に追加して、有効にしておきます。

myapp/views.py

from django.shortcuts import render

def my_view(request):
    return render(request, 'spam.html', {'name': 'egg'})

myapp/templates/spam.html

Hello {{ name }}

myproject/urls.py

from django.contrib import admin
from django.urls import path
from myapp import views

urlpatterns = [
    path('', views.my_view),
    path('admin/', admin.site.urls),
]

デフォルトのテンプレートローダーの動作を確認

この状態でrunserverを起動し、 http://127.0.0.1:8000/ にアクセスすると、myapp/templates/spam.html` が使われる表示となります。

テンプレートローダーを自作する

今回は、データベースからテンプレートデータをロードするようなローダーを作成してみます。

models.py

テンプレートデータを保存しておくモデルです。 name にテンプレート名(テンプレートパス)を入れておき、 source にテンプレートの内容を入れておく想定です。

from django.db import models

class Template(models.Model):
    name = models.CharField(max_length=255)
    source = models.TextField()

    def __str__(self):
        return f"{self.name}"

makemigrationsとmigrateでテーブルを作っておきます。

python manage.py makemigrations myapp
python manage.py migrate myapp

myapp/admin.py

Django adminからTemplateのモデルを編集できるように、admin.pyを書き換えます。

from django.contrib import admin
from .models import Template

admin.site.register(Template)

myapp/db_loader.py

テンプレートローダーのクラスです。

from django.template.loaders.base import Loader as BaseLoader
from django.template import Origin
from .models import Template

class DatabaseOrigin(Origin):
    def __init__(self, name, template_name=None, loader=None, object=None):
        super().__init__(name, template_name, loader)
        self.object = object


class Loader(BaseLoader):
    def get_contents(self, origin):
        return origin.object.source

    def get_template_sources(self, template_name):
        # データベースからテンプレートデータを取得
        template = Template.objects.filter(name=template_name).first()
        if not template:
            return []
        # テンプレートのデータをカスタムのOriginクラスでラップして返す。
        origin = DatabaseOrigin(
            name=template_name,
            template_name=template_name,
            loader=self,
            object=template)
        return [origin]

これでテンプレートローダーは完成です。

自作したテンプレートローダーを有効にする

作成したテンプレートローダーを有効にしてみます。 settings.pyTEMPLATES 設定を書き換えます。

APP_DIRS を削除(またはコメントアウト)して、 loaders キーを追加し、作成したテンプレートローダーと app_directories.Loader を設定します。

settings.py(抜粋)

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        # 'APP_DIRS': True,  # APP_DIRSは、loaders設定を入れる場合には、設定できない。
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
            'loaders': [
                # 複数のテンプレートローダーが設定されている場合、前にあるほうから、順番にテンプレートを探す
                (
                    # 自作したテンプレートローダー
                    'myapp.db_loader.Loader',
                ),
                (
                    # Django adminなどが動作するように、app_directories.Loaderを有効にしておく
                    'django.template.loaders.app_directories.Loader',
                ),
            ]
        }
    },
]

動かしてみる

この状態で http://127.0.0.1:8000 にアクセスすると、データベースにはテンプレートデータがまだ無いので、ファイルから読み込まれたテンプレートが使用され、挙動に変化はありません。

Django adminからnameを spam.html としたテンプレートデータを追加します。

そうすると、今度は自作したテンプレートローダー側が読み取った、データベース上のデータがテンプレートとして使用されます。

データベース上のテンプレートデータを利用するテンプレートローダーを作成することができました。

サンプルコード一式

github.com

参考

DjangoCongressJP 2023に参加しました

2023/10/7にDjangoCongressJP 2023が開催され、参加してきました。

django.connpass.com

私は、登壇者として、また、スタッフとしての参加となりました。

登壇者として

Djangoテンプレートエンジンの使い方や、カスタマイズについて発表しました。

初心者~中級者向けの内容といった感じでした。

自分では、テンプレートは何度も検証したことがあり、コードも読んだことがあったので、再検証しつつ、整理してまとめて発表する、といった形になりました。

スタッフとしての作業があったり、仕事も忙しかったりして、スライドの完成がぎりぎりになってしまったので、発表練習はできなかったのですが、なんとかこなせました。

資料の最後のほうの、テンプレートローダーをカスタマイズする例は、知っておくと面白いかもしれません。 テンプレートを動的に変更可能な機能を作り込めます。

たとえば、CMSで静的ファイルを生成するタイプのものだとか、利用者が画面レイアウトをカスタマイズできる機能だとかに応用できます。

ユーザーに自由に使わせる場合は、セキュリティにも気をつける必要があるので、単純にそのまま使えるかはわからいですけども。

スタッフとして

スタッフとしては、例年通りCfPの選考、プログラムの検討などをやりつつ、今回は、会場を提供いただいたサイボウズさんのスタッフとのやりとりも担当していました。

また、先日から実施している Django利用状況アンケート2023 も、私が主に進めていたものなので、会場に出す案内の紙(アンケートのQRコードが書かれたやつ)を用意したりもしてました。

当日、会場ではタイムキーパーとしてRoom2のほうに主ににいました。

今年は、CfPの応募が少なかったのですが、来年はもっと増えてほしいです!(自分も毎年提出してます、来年も出す)

参加者として

Room2のほうにスタッフとしてほとんどの時間待機する必要があったので、Room2のほうの発表を聞いていた感じです。

みなさんしっかり資料を作り込んでいて、発表もうまかったので、聞きやすかったと思います。

今回はコロナ禍の制限もなく、会場で飲食を伴う懇親会(パーティ)がありました。

100人規模のパーティで、わいわいできてよかったです。

オフラインの会は、普段話さない人とも話せたりするので、やはり楽しい。このオフライン会の良さを復活させていきたいと思います。

また来年も参加できると良いなーと思います。

Django利用状況アンケート2023

Djangoの利用状況に関するアンケートを実施しています。 Djangoを使っている方は、ぜひご協力ください。 回答受付期間は、2023/10/2~2023/10/16。 forms.gle 集計結果はインターネット上で公開し、また今月のPyCon APAC 2023のポスターセッションでも発表予定です。

2023/10/31 追記

集計結果はこちら。

https://djangoproject.jp/whouses/pyconapac2023-django-ja.pdf

django-extensionsの runscript コマンドでスクリプトを実行する

django-extensionsには、スクリプトファイルを実行するための runscript というコマンドが用意されている。

このコマンドを使うと、Djangoのコンテキスト(つまり、Djangoのsettings.pyが適用された、 django.setup() を実行済みの状態)で、Pythonスクリプトを実行できる。

RunScript — django-extensions 3.2.3 documentation

試したバージョンは、Python 3.10、Django 4.2、django-extensions 3.2.3。

scriptsフォルダにスクリプトファイルを用意する

django-extensionsをセットアップ済みのプロジェクトで作業する。

Djangoのプロジェクトディレクトリに、 scripts という名前のフォルダを作成し、スクリプトファイルはその中に作成する。

scriptsフォルダ内のスクリプトPythonモジュールとして再利用しないのであれば、scriptsフォルダには __init__.py を作成しなくてもよい。

今回作成したのは、 scripts/foo.py というファイル。

ディレクトリ構造とファイルの配置は次の通り。

.
├── db.sqlite3
├── manage.py
├── myproject
│     ├── __init__.py
│     ├── asgi.py
│     ├── settings.py
│     ├── urls.py
│     └── wsgi.py
└── scripts
    └── foo.py

scripts/foo.py:

from django.contrib.auth.models import Permission

def run(*args):
    """fooという名前のスクリプト
    """
    print(f"args: {args}")
    # ORMも使える
    print(Permission.objects.count())

今回作成したファイルでは、Permissionモデルのレコード件数を取得して画面に出力している。

このコードは、DjangoのORMが使われる。

関数名は run としておく。runscriptコマンドから渡される引数は、可変長引数で受け取れる。

runscriptコマンドでスクリプトを実行する

django-extensionsをセットアップ済みのプロジェクトでは、manage.pyコマンドのサブコマンドとして runscript コマンドが使えるようになっている。

python manage.py runscript <script_name> のように、スクリプトファイル名の .py を除いた名前で実行できる。

$ python manage.py runscript foo --script-args hoge fuga
args: ('hoge', 'fuga')
24

スクリプトには、 --script-args オプションでコマンドから追加パラメータの文字列を渡すことができる。スペース区切りで複数の値を指定できる。

実行結果では、ORMが動作して、正常にcount()の結果が表示されていることがわかる。

まとめ

  • django-extensionsには、 runscript コマンドがある
  • runscript コマンドでは、Djangoのコンテキストでスクリプトファイルを実行できる
  • Djangoのプロジェクトフォルダに scripts/<script name>.py のようなファイルでスクリプトを用意する
  • python manage.py runscript <script name> --script-args param1 param2 .. のように実行する

DjangoCongress JP 2023のチケット販売が開始されました

DjangoCongress JP 2023 のチケット販売が開始されました。私はスタッフ兼登壇者として参加します。

django.connpass.com

パーティーでは色々な人と話せるのでおすすめです。久しぶりにパーティー有りの会なので楽しみです。