Qiita Django Advent Calendar 2021 2日目は、2021年12月リリース予定のDjango 4.0で追加された新機能について解説します。

【注意】 なお、2021年12月2日時点では、Django 4.0はまだ正式にリリースされてません。この記事はrelease candidate 1版を元に執筆しました。

祝・Django 4.0リリース!

祝・Django 4.0リリース!

公式サイトでのリリース情報は以下を参照してください。

Django 4.0 release notes - UNDER DEVELOPMENT | Django documentation | Django

4.0のサポート期限は2023年8月までです。 一方で、一つ前のバージョン3.2 LTS(long-term support)のサポート期限は2024年4月です。 現在3.2 LTSを使っている場合は、4.0にバージョンアップすることでサポート期限が短くなることに注意してください。

各バージョンがLTSなのか否か、サポート期限がいつまでなのかについては以下公式ドキュメント「Supported Versions」を参照してください。

Download Django | Django

zoneinfo default timezone implementation

タイムゾーンの実装にデフォルトでzoneinfoを使うようになりました。 zoneinfoはPython 3.9から追加された標準モジュールです。 Django 3.2まではサードパーティライブラリのpytzがデフォルトでしたが、4.0からは非推奨になり、5.0で廃止になる予定です。 Django 3.2ではデフォルトではないものの、zoneinfoを使うこともできます。Django 4.0にバージョンアップする前にzoneinfoを導入することは可能です。

以下の条件すべてに当てはまるアプリケーションは、pytzからzoneinfoに移行することで挙動が変わる可能性があります。タイムゾーンの計算に正確さが求められる場合は、十分検証してから移行しましょう。

  • TIME_ZONE にUTC以外のタイムゾーンを指定している
  • pytzのnormalize()localize()を使っている

pytzを当面使い続ける必要がある場合は、USE_DEPRECATED_PYTZTrueを設定すると、デフォルトでpytzを使うようにできます。 また、zoneinfoはpytzとはインターフェイスが異なるため、移行にあたってコードの書き換え箇所が少し多めになってしまいます。 pytz_deprecation_shimはzoneinfoに近いインターフェイスでpytzを使えるようにするライブラリです。 今すぐzoneinfoに移行できない場合でも、pytzを使っている箇所をpytz_deprecation_shimを使うように書き換えておくと、後々zoneinfoに移行する際に変更量を減らすことができます。

Functional unique constraints

UniqueConstraint()の引数に式やデータベース関数を使えるようになりました。

リリースノートに載っているサンプルコードを参考に、実際にアプリケーションを作ってみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
"""my_models/models.py"""
from django.db import models
from django.db.models import UniqueConstraint
from django.db.models.functions import Lower


class MyModel(models.Model):
    first_name = models.CharField(max_length=255)
    last_name = models.CharField(max_length=255)

    class Meta:
        constraints = [
            UniqueConstraint(
                Lower('first_name'),
                Lower('last_name').desc(),
                name='first_last_name_unique',
            ),
        ]

実際にどんなSQLが発行されるのか見てみましょう(データベースはPostgreSQL 14.1を使用)。 CREATE UNIQUE INDEXLOWER関数が使われていることがわかります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ ./manage.py makemigrations
Migrations for 'my_models':
  my_models/migrations/0001_initial.py
    - Create model MyModel
    - Create constraint first_last_name_unique on model mymodel
$ ./manage.py sqlmigrate my_models 0001
BEGIN;
--
-- Create model MyModel
--
CREATE TABLE "my_models_mymodel" ("id" bigserial NOT NULL PRIMARY KEY, "first_name" varchar(255) NOT NULL, "last_name" varchar(255) NOT NULL);
--
-- Create constraint first_last_name_unique on model mymodel
--
CREATE UNIQUE INDEX "first_last_name_unique" ON "my_models_mymodel" ((LOWER("first_name")), (LOWER("last_name")) DESC);
COMMIT;

shellコマンド上でデータを登録してみます。大文字・小文字を区別せずにユニーク制約が効いています。

1
2
3
4
5
6
7
8
>>> from my_models.models import MyModel
>>> MyModel.objects.create(first_name="Taro", last_name="Yamada")
<MyModel: MyModel object (1)>
>>> MyModel.objects.create(first_name="taro", last_name="yamada")  # 先頭を小文字にして再登録
Traceback (most recent call last):
省略
django.db.utils.IntegrityError: duplicate key value violates unique constraint "first_last_name_unique"
DETAIL:  Key (lower(first_name::text), lower(last_name::text))=(taro, yamada) already exists.

scrypt password hasher

新しいscrypt password hasher(データベースにパスワードを保存する際に使われるアルゴリズム)としてdjango.contrib.auth.hashers.ScryptPasswordHasherが追加されました。 これは、RFC 2898で定義されている鍵導出関数PBKDF2よりセキュアであるとして推奨されています。 ただし、OpenSSL 1.1以上が必要なため、デフォルトでは有効になっていません。 また、メモリ使用量も他のscrypt password hasherより増大します。

導入する際は、OpenSSL 1.1以上をインストールし、十分なメモリを確保した上、PASSWORD_HASHERSの先頭にdjango.contrib.auth.hashers.PBKDF2PasswordHasherdjango.contrib.auth.hashers.ScryptPasswordHasher1を追加(つまり、優先順位を一番上にする)してください。

既存のデータベースに保存されたハッシュ値を更新する作業は必要はありません(というかできません)。上記の変更のみでOKです。 Djangoには、ログイン成功時に以前のログイン時より優先順位が高いscrypt password hasherが存在すれば、それを使ってパスワードを再度ハッシュ化し、データベース上の値を更新する仕組みがあります。

Redis cache backend

新しいRedisキャッシュバックエンドdjango.core.cache.backends.redis.RedisCacheが追加されました。Django 4.0未満では、サードパーティのdjango-redisを使うのが定番でした。

django.core.cache.backends.redis.RedisCacheの具体的な使い方については以下公式ドキュメントを参照してください。

Django’s cache framework | Django documentation | Django

Template based form rendering

Formクラス、FormsetsErrorListクラスにtemplate_name属性を指定することで、テンプレートファイルを使ってフォームのレンダリング内容をカスタマイズできるようになりました。

例として、お問い合わせフォームを模したアプリケーションを作ってみます。

contact/views.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
"""contact/views.py"""
from django.urls import reverse_lazy
from django.views.generic.edit import FormView

from .forms import ContactForm


class IndexView(FormView):
    template_name = "contact/index.html"
    form_class = ContactForm
    success_url = reverse_lazy("contact:index")

contact/urls.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
"""contact/urls.py"""
from django.urls import path

from . import views

app_name = "contact"

urlpatterns = [
    path("", views.IndexView.as_view(), name="index"),
]

contact/forms.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
"""contact/forms.py"""
from django import forms


class ContactForm(forms.Form):
    name = forms.CharField(max_length=50, label="お名前")
    email = forms.EmailField(
        max_length=100,
        label="メールアドレス",
        help_text="お問い合わせの返信のために使います",
    )
    content = forms.CharField(widget=forms.Textarea(), max_length=1000, label="内容")

contact/templates/contact/index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html lang="ja">
<head>
  <!-- Required meta tags -->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>お問い合わせ</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
</head>
<body>
  <section class="section">
    <div class="container">
      <form action="{% url 'contact:index' %}" method="POST">
        {% csrf_token %}
        {{ form }}
        <div class="field is-grouped">
          <div class="control">
            <button class="button is-link">送信</button>
          </div>
        </div>
      </form>
    </div>
  </section>
</body>
</html>

このとき、http://127.0.0.1:8000/contact/は以下のような画面になります。 {{ form }}はtableタグを使う前提のHTMLが出力されますが、contact/templates/contact/index.htmlはtableタグを使っていないので表示が変な感じです。

デフォルトでのフォームの表示内容

デフォルトでのフォームの表示内容

これをBulmaのForm controlsに合わせたフォームにしてみましょう。 まず、contact/forms.pytemplate_nameフィールドを追加します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
"""contact/forms.py"""
from django import forms


class ContactForm(forms.Form):
    name = forms.CharField(max_length=50, label="お名前")
    email = forms.EmailField(
        max_length=100,
        label="メールアドレス",
        help_text="お問い合わせの返信のために使います",
    )
    content = forms.CharField(widget=forms.Textarea(), max_length=1000, label="内容")
    template_name = "contact/forms/contact.html"  # これを追加

次に、Djangoのソースコードからforms/templates/django/forms/table.htmlをコピーして、contact/templates/contact/forms/contact.htmlを作ります。 contact/templates/contact/forms/contact.htmlは以下の内容に編集します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{% if errors %}
  <div class="notification is-danger">
    {{ errors }}
    {% if not fields %}
    <ul>
      {% for field in hidden_fields %}<li>{{ field }}</li>{% endfor %}
    </ul>
    {% endif %}
  </div>
{% endif %}
{% for field, errors in fields %}
  <div class="field">
    <label class="label{% with classes=field.css_classes %}{% if classes %} {{ classes }}"{% endif %}{% endwith %}">{% if field.label %}{{ field.label_tag }}{% endif %}</label>
    <div class="control">
      {{ errors }}
      {{ field }}
    </div>
    {% if field.help_text %}
      <p class="help">{{ field.help_text }}</p>
    {% endif %}
    {% if forloop.last %}
      {% for field in hidden_fields %}{{ field }}{% endfor %}
    {% endif %}
  </div>
{% endfor %}
{% if not fields and not errors %}
  {% for field in hidden_fields %}{{ field }}{% endfor %}
{% endif %}

http://127.0.0.1:8000/contact/を再度表示すると、以下のような画面になります。

カスタマイズ後のフォームの表示内容

カスタマイズ後のフォームの表示内容


  1. (2021/12/02 15:09) @aki_yokさんからの指摘により、typoを修正しました。@aki_yok さん、ありがとうございました。 ↩︎