広告 プログラミング

【Django】Datalist用のWidgetとFormを実装

※本ページには、プロモーション(広告)が含まれています。

【Django】Datalist用のWidgetとFormを実装

今回は、Djangoを用いてHTML5の機能の一つであるDatalistを実装する方法について解説します。

入力候補を表示させたいときに便利です。

ゆると
ゆると

今回は、WidgetやFormを自作して、汎用的な形式で提供するので、他の事例でも活用できると思います。

実際はTomSelectなどSelectに検索機能を搭載した方が使いやすいですが、実装時の備忘録として残しておくことにしました。

DataListとは?Selectとの違い

Datalistは、HTML5から新たに追加されたhtml要素で、入力時の候補を表示できる機能を持っています。

<datalist>は HTML の要素で、この要素には <option>要素の集合が含まれ、他のコントロール内で選択できる許容または推奨オプションを表します。

<datalist>: HTML データリスト要素

実際の動きとしては、以下に示すような感じで、input要素にカーソルを当てると入力時の候補が表示されます。

また、DatalistとSelectの違いとしては、以下の2点が挙げられます。

観点DatalistSelect
選択肢の内容可否変更できる(※ベースはinput要素になる)変更できない
検索機能検索可能(デフォルトでは)検索不可能
※Javascriptライブラリを使えば実現可能
DatalistとSelectの違い2つ

上記にも記載した通り、Datalistのベースはinput要素になるため、フォーム送信時に決まった形式でデータを送信する場合は、一工夫必要です。

工夫点は、実装の中で解説します。

ゆると
ゆると

実現したいことと事例2つ

中身の解説をする前に、実現したいことと事例2つを紹介します。

【実現したいこと】外部キー(Foreign Key)の設定にDatalistを使う

Djangoで開発を行っている場合、他のモデルで定義した情報を外部キーとして紐づけたいときがあります。

デフォルトの設定では、Select要素が用いられますが、紐づけたモデルのデータが増えてくると検索が大変になります。

この課題を解決する方法の一例として、今回は、Datalistを使うことにしました。

TomSelectなどのJavascriptライブラリを活用する方法もありますよ。

ゆると
ゆると

1つ目のDatalistの活用事例として、書籍と著者を紐づけることが挙げられます。

この場合、著者が外部キー(Foreign key)となります。

「著者欄に候補が表示される」という構成にするのが目標となります。

ゆると
ゆると

2つ目のDatalistの活用事例として、株式と企業を紐づけることが挙げられます。

この場合、企業が外部キー(Foreign key)となります。

先ほどと同様に「企業欄に候補が表示される」という構成にするのが目標となります。

ゆると
ゆると

全体構成

前置きはこの位にしておき、実際に作り込んでいきましょう。

今回は、書籍と著者を紐づける例を題材に、解説していきたいと思います。

また、完成版のGitHubのパスを以下に示しておきます。

https://github.com/yuruto-free/implement-custom-form-using-django/tree/master

ディレクトリツリー

全体構成を伝えるために、ディレクトリツリーを紹介します。

./
|-- docker-compose.yml
|-- LICENSE
|-- README.md
|-- env.sample
|-- .env
|-- env_files/
|   |-- django/
|   |   |-- README.md
|   |   |-- env.sample
|   |   `-- .env
|   `-- postgres/
|       |-- README.md
|       |-- env.sample
|       `-- .env
|-- postgres/
|   `-- Dockerfile
`-- django/
    |-- Dockerfile
    |-- pyproject.toml
    |-- README.md
    `-- app/
        |-- manage.py
        |-- config/
        |   |-- asgi.py
        |   |-- urls.py
        |   |-- wsgi.py
        |   |-- __init__.py
        |   `-- settings.py
        |-- book/
        |   |-- __init__.py
        |   |-- admin.py
        |   |-- apps.py
        |   |-- forms.py
        |   |-- models.py
        |   |-- tests.py
        |   |-- urls.py
        |   `-- views.py
        |-- utils/
        |   |-- __init__.py
        |   |-- templates/
        |   |   |-- renderer/
        |   |   |   |-- utils_form.html
        |   |   |   `-- utils_datalist_javascript.html
        |   |   `-- widgets/
        |   |       |-- utils_datalist.html
        |   |       `-- utils_datalist_option.html
        |   |-- admin.py
        |   |-- apps.py
        |   |-- forms.py
        |   |-- models.py
        |   |-- tests.py
        |   |-- views.py
        |   `-- widgets.py
        `-- templates/
            |-- base.html
            |-- list.html
            `-- book/
                |-- index.html
                |-- author_list.html
                |-- author_form.html
                |-- book_list.html
                `-- book_form.html

では、実装内容とあわせて解説していきます。

読みながら作れるように、コードも一緒に掲載しますね。

ゆると
ゆると

開発に向けた準備3ステップ

まずは、開発環境を整えていきましょう。

また、環境構築した後の実装結果は、GitHubの下記から取得できます。

https://github.com/yuruto-free/implement-custom-form-using-django/tree/v0.1.0

サービス一覧と環境変数の設定

今回構築する環境は、以下のような階層構造になります。

上記の図は、あくまでイメージ図となります。

ゆると
ゆると

docker-compose.yml

上記の構造を実現するために、以下のようなdocker-compose.ymlを作成しました。

services:
  django:
    build:
      context: ./django
      args:
        - UID
        - GID
        - ARCHITECTURE=${HOST_ARCHITECTURE:-arm64v8}
        - TZ=${HOST_TIMEZONE:-Asia/Tokyo}
    image: django.custom-form
    container_name: django.custom-form
    env_file:
      - ./env_files/django/.env
      - ./env_files/postgres/.env
    environment:
      - DJANGO_DB_HOST=postgres
      - DJANGO_DB_PORT=5432
      - DJANGO_LANGUAGE_CODE=${HOST_LANGCODE:-ja}
      - DJANGO_TIME_ZONE=${HOST_TIMEZONE:-Asia/Tokyo}
    volumes:
      - static.custom-form:/var/www/static
      - ./django/bashrc:/opt/home/.bashrc:ro
      - ./django/app:/opt/app
      - ./django/pyproject.toml:/opt/pyproject.toml
    ports:
      - ${HOST_ACCESS_PORT:-3001}:8000
    depends_on:
      - postgres
    networks:
      - custom-form-dev-net
    restart: always

  postgres:
    build:
      context: ./postgres
      args:
        - ARCHITECTURE=${HOST_ARCHITECTURE:-arm64v8}
        - TZ=${HOST_TIMEZONE:-Asia/Tokyo}
    image: postgres.custom-form
    container_name: postgres.custom-form
    env_file:
      - ./env_files/postgres/.env
    environment:
      - PGDATA=/var/lib/postgresql/data/pgdata
      - LANG=C
      - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --locale=C
    volumes:
      - db.custom-form:/var/lib/postgresql/data
    expose:
      - 5432
    networks:
      - custom-form-dev-net
    restart: always

networks:
  custom-form-dev-net:
    name: custom-form-dev-net

volumes:
  static.custom-form:
    name: static-custom-form
    driver: local
  db.custom-form:
    name: postgres-custom-form
    driver: local

上記のdocker-compose.ymlファイルを使う上で、環境変数の設定が必要になります。

このため、docker-compose.ymlと同じ階層に下記の環境変数を記載した.envファイルを作成してください。

環境変数名詳細設定例
HOST_ACCESS_PORTホスト環境からコンテナ内にアクセスする際のポート番号3001
HOST_ARCHITECTUREホスト環境のアーキテクチャarm64v8
HOST_TIMEZONEホスト環境のタイムゾーンAsia/Tokyo
HOST_LANGCODEホスト環境の言語設定ja
.envファイルに記載する環境変数一覧

コピペ用にサンプルを掲載しておきますね。

HOST_ACCESS_PORT=3001
HOST_ARCHITECTURE=arm64v8
HOST_TIMEZONE=Asia/Tokyo
HOST_LANGCODE=ja

今回は、Raspberry Pi 4 Model B上で開発を行っているため、arm64v8になっています。

ゆると
ゆると

Django向けの.envファイル

同様の方針で、Djangoコンテナで用いる環境変数も設定しておきましょう。

下記の環境変数を記載した.envファイルを./env_files/django/.envに保存してください。

環境変数名詳細設定例
DJANGO_SECRET_KEYDjangoで使うシークレットキーdjango-insecure-key
DJANGO_SUPERUSER_NAMEDjangoのスーパーユーザ名superuser
DJANGO_SUPERUSER_EMAILDjangoのスーパーユーザのE-mailアドレスsuperuser@example.com
DJANGO_SUPERUSER_PASSWORDDjangoのスーパユーザのパスワードsuperuser-password
.envファイルに記載する環境変数一覧

コピペ用にサンプルを掲載しておきますね。

DJANGO_SECRET_KEY=django-insecure-key
DJANGO_SUPERUSER_NAME=superuser
DJANGO_SUPERUSER_EMAIL=superuser@local.access
DJANGO_SUPERUSER_PASSWORD=superuser-password

PostgreSQL向けの.envファイル

PostgreSQL(データベース)にも.envファイルを用意したので、同様の手順で設定します。

下記の環境変数を記載した.envファイルを./env_files/postgres/.envに保存してください。

環境変数名詳細設定例
POSTGRES_USERデータベース作成時に新規作成するPostgreSQLのユーザ名admin
POSTGRES_PASSWORDPostgreSQLのユーザのパスワードadminpassword
POSTGRES_DB新規作成するデータベース名postgres
.envファイルに記載する環境変数一覧

先ほどと同様に、コピペ用のサンプルを掲載しておきますね。

POSTGRES_USER=admin
POSTGRES_PASSWORD=adminpassword
POSTGRES_DB=postgres

環境構築

続いて、Dockerfileやpoetryの設定を行います。

DjangoのDockerfile

下記に記載した内容を./django/Dockerfileに書き込んでください。

ARG ARCHITECTURE=arm64v8

From ${ARCHITECTURE}/python:3.12.10-alpine3.21
ARG UID
ARG GID
ARG USERNAME=user
ARG GROUPNAME=user
ARG TZ=Asia/Tokyo
ENV APP_ROOT_PATH=/opt/app

LABEL maintainer="yuruto-free"
LABEL description="Build the environment of Django application"

COPY ./pyproject.toml /opt/pyproject.toml

RUN    apk update \
    && apk upgrade \
    \
    # Install mandatory libraries
    \
    && apk add --no-cache bash tzdata libpq-dev pcre-dev libxml2-dev gettext \
    \
    # Install relevant libraries for development
    \
    && apk add --no-cache gcc musl-dev libffi-dev g++ libgcc libstdc++ libxslt-dev \
                          python3-dev libc-dev linux-headers curl shadow \
    && ln -s /usr/share/zoneinfo/${TZ} /etc/localtime \
    && pip install --upgrade setuptools \
    && pip install poetry \
    && groupadd -g ${GID} ${GROUPNAME} \
    && useradd --shell /bin/bash --no-log-init --create-home --home-dir /opt/home --gid ${GID} --uid ${UID} ${USERNAME} \
    && mkdir -p ${APP_ROOT_PATH} \
    && chown -R ${USERNAME}:${GROUPNAME} /opt/home \
    && cd /opt \
    && poetry config virtualenvs.create false \
    && poetry install --no-interaction --no-ansi \
    && chown -R ${USERNAME}:${GROUPNAME} /opt/poetry.lock \
    && cd / \
    && rm -rf /root/.cache /var/cache/apk/* /tmp/*

WORKDIR ${APP_ROOT_PATH}
USER ${USERNAME}

CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

コンテナ内でライブラリ等を追加しやすいように、GCCなどコンパイル時に必要なツールは残しています。

ゆると
ゆると

poetry向けのtomlファイル

Djangoで環境構築を行う上で、仮想環境があると便利です。

今回は、poetryを使うことにしたので、下記の内容を./django/pyproject.tomlに反映してください。

[project]
name = "app"
version = "0.0.1"
authors = [{ name="yuruto-free", email="103588113+yuruto-free@users.noreply.github.com" },]
maintainers = [{ name="yuruto-free", email="103588113+yuruto-free@users.noreply.github.com" },]
description = "Backend application using Django"
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.10"

[tool.poetry]
package-mode = false

[tool.poetry.dependencies]
python = "^3.12"
django = "^5.2"
psycopg = { version="^3.2.6", extras=["c", "pool"] }

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

PostgreSQL向けのDockerfile

下記に記載した内容を./postgres/Dockerfileに書き込んでください。

ARG ARCHITECTURE=arm64v8

From ${ARCHITECTURE}/postgres:17-alpine3.21
ARG TZ=Asia/Tokyo

LABEL maintainer="yuruto-free"
LABEL description="Build the environment of PostgreSQL"

RUN    apk update \
    && apk upgrade \
    && apk add --no-cache bash tzdata \
    && ln -s /usr/share/zoneinfo/${TZ} /etc/localtime

以上で、Docker imageを作成するための準備が整いました。

Docker imageの作成

では、下記のコマンドを用いて、Docker imageを作成していきます。

docker-compose build --no-cache --build-arg UID="$(id -u)" --build-arg GID="$(id -g)"

しばらくすると、下記のようなイメージが作成されます。

docker images
REPOSITORY              TAG                   IMAGE ID       CREATED        SIZE
django.custom-form      latest                9a8120bdc502   XX hours ago   582MB  # <--- 作成されたDocker image
postgres.custom-form    latest                1edc42a7a9e2   XX hours ago   419MB  # <--- 作成されたDocker image
arm64v8/python          3.12.10-alpine3.21    cce969eea970   XX weeks ago   52.4MB
arm64v8/postgres        17-alpine3.21         6b8369bf21ff   XX months ago  270MB
# 以下、省略

環境によっては意図通り動作しない可能性もあります。エラーメッセージに応じて、修正を行ってください。

ゆると
ゆると

初期設定

データベースの初期設定とスーパーユーザの作成を行っておきます。

# =====================
# データベースの初期設定
# =====================
# ホスト環境での操作
docker-compose run --rm django bash
# コンテナ内での操作
python manage.py makemigrations
python manage.py migrate

# =====================
# スーパユーザの作成
# =====================
echo 'from django.contrib.auth.models import User; User.objects.create_superuser("'${DJANGO_SUPERUSER_NAME}'", "'${DJANGO_SUPERUSER_EMAIL}'", "'${DJANGO_SUPERUSER_PASSWORD}'")' | python manage.py shell

# ホスト環境に戻る
exit # or press Ctrl + D

以上で、今回の開発に向けた準備は終了です。

試しに、以下のコマンドを実行し、ここまでの実装内容を確認しておきましょう。

docker-compose up -d
# その後、http://your-server-ip-address:your-port-number にアクセス
# 例)http://192.168.11.30:3001

Webブラウザで該当ページにアクセスし、下記のようなスタートページが表示されれば成功です。

続いて、本題のDatalistに関する実装について、解説します。

Widget / Field / Form / Templateの作成

早速、対象のWidget/Field/Form/Templateを作成していきます。

今回の機能は、メイン機能と分けた構成としたいため、utilsというアプリ内にこれらの機能を実装していきます。

具体的な実装例は、GitHubの以下のパスに格納しているので、あわせてご覧ください。

https://github.com/yuruto-free/implement-custom-form-using-django/tree/add-utils-app(v0.1.1)

utilsアプリの作成

djangoアプリのコンテナ内に入り、以下のコマンドを実行します。

# ホスト環境での操作
docker exec -it django.custom-form bash
# コンテナ内での操作
python manage.py startapp utils
cd utils
touch forms.py widgets.py
mkdir -p templates/renderer templates/widgets
exit # or press Ctrl + D

また、config/settings.pyの下記も更新しておきます。

# 省略
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [
            os.path.join(BASE_DIR, 'templates'),
            os.path.join(BASE_DIR, 'utils', 'templates'),  # <--- 追加
        ],
        'APP_DIRS': True,
        # 以下、省略
    }
]

GitHubからcloneした人は、この処理は実施済みのため、実施不要です。

ゆると
ゆると

Formの内部構造

中身の解説に入る前に、Formの内部構造について説明しておきます。

私の理解では、DjangoのFormは以下のような構造になっています。

また、HTMLのデータ構造を対応付けると、以下のようになります。

<form action="/login/" method="POST">
  <!-- Form -->
  <div class="row row-cols-1 g-4">
    <!-- Field (ユーザ名) -->
    <div class="col">
      <label class="form-label">ユーザー名</label>
      <!-- Widget (ユーザ名) -->
      <input
        type="text"
        name="username"
        autofocus=""
        autocapitalize="none"
        autocomplete="username"
        maxlength="128"
        class="form-control"
        required=""
        id="id_username"
      />
      <!-- end Widget (ユーザ名) -->
    </div>
    <!-- end Field (ユーザ名) -->
    <!-- Field (パスワード) -->
    <div class="col">
      <label class="form-label">パスワード</label>
      <!-- Widget (パスワード) -->
      <input
        type="password"
        name="password"
        autocomplete="current-password"
        class="form-control"
        required=""
        id="id_password"
      />
      <!-- end Widget (パスワード) -->
    </div>
    <!-- end Field (パスワード) -->
  </div>
  <!-- end Form -->
  <!-- 以下、省略 -->
</form>

このため、下記のような構造になっていると整理できますね。

項目構成要素
Form複数のFieldユーザ名(label, input)、パスワード(label, input)
Fieldlabel要素とwidgetのペアユーザ名の「label要素」と「input要素」
Widgetフィールド要素そのものinput要素、select要素、チェックボックスなど
DjangoにおけるFormの内部構造

上記を踏まえてWidgetとFormを作っていきます。

WidgetとFieldの作成

MDN Web Docsの解説から、Datalist要素は、以下のような構成を取ることが分かります。

<!-- **valid-pattern-name**: text, time, number などを取る -->
<input
  type="**valid-pattern-name**"
  list="datalist-id"
  id="input-id"
  name="input-name"
/>
<datalist id="datalist-id">
  <option value="value1">value1</option>
  <option value="value2">value2</option>
  <option value="value3">value3</option>
  <!-- 以下省略 -->
</datalist>

上記を踏まえると、Widgetには、以下の情報が保持できていれば良いことになります。

項目名理由上記の該当箇所設定例
type入力形式を指定するため。valid-pattern-nametext
listinput要素とDatalist要素を紐づけるため。datalist-iddatalist_xxx
dataset.valueDjangoの外部キー(Foreign key)の情報を保持するため。-(新規追加)data-value="fk-1"
Widgetを実装する際に必要な項目

唐突にdatasetが登場していますが、これは、input要素に外部キー(Foreign key)の値が表示されてしまうのを回避するための処置です。

ベースのWidgetがSelectであることを考慮し、Djangoのオリジナルの実装をもとにDatalist要素のWidgetを実装すると以下のようになります。

# utils/widgets.py
from django import forms

class Datalist(forms.Select):
  # input要素のtypeに指定する内容(text, numberなどが指定可能)
  input_type = 'text'
  # Datalist要素のlistに指定する内容(未指定の場合は、「field名_datalist」とする)
  input_list = ''
  # optionのvalueに該当するデータの設定方法
  #  True:  dataset.valueの形式で設定
  #  False: <option value="***">の形式で設定
  use_dataset_attr = False
  # Datalist要素をレンダリングする際のテンプレートファイル名
  template_name = 'widgets/utils_datalist.html'
  # Datalist要素のoptionをレンダリングする際のテンプレートファイル名
  option_template_name = 'widgets/utils_datalist_option.html'

  def __init__(self, attrs=None):
    # Widgetのインスタンス生成時にattrsの指定があれば、その内容に基づいて処理
    if attrs is not None:
      self.input_type = attrs.get('type', self.input_type)
      self.input_list = attrs.pop('list', self.input_list)
      self.use_dataset_attr = attrs.pop('use-dataset', self.use_dataset_attr)
    super().__init__(attrs)

  def get_context(self, name, value, attrs):
    context = super().get_context(name, value, attrs)
    # templateファイル内で使用する変数の定義
    # 親クラスで設定される値は、GitHubの下記のリンク参照(django/forms/widgets.py::Widgetsクラス::get_contextメソッド)
    # https://github.com/django/django/blob/main/django/forms/widgets.py#L315
    context['widget']['type'] = self.input_type
    context['widget']['id'] = self.input_list if self.input_list else f'{name}_datalist'
    context['widget']['use_dataset'] = self.use_dataset()

    return context

  def use_dataset(self):
    return bool(self.use_dataset_attr)

プログラムとDatalist要素の対応関係もあわせてまとめておきます。

変数名インスタンス生成時に渡すキー名項目名Datalist要素の例の該当箇所
input_type'type'typevalid-pattern-name
input_list'list'listdatalist-id
use_dataset_attr'use-dataset'dataset.value-(新規追加)
プログラムとDatalist要素の対応関係

また、templateファイルもあわせて記載しておきます。

{# utils/templates/widgets/utils_datalist.html #}
<input type="{{ widget.type }}" list="{{ widget.id }}" name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %} />
<datalist id="{{ widget.id }}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
  <optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
  {% include option.template_name with widget=option use_dataset=widget.use_dataset %}
  {% endfor %}{% if group_name %}
  </optgroup>{% endif %}{% endfor %}
</datalist>
{# utils/templates/widgets/utils_datalist_option.html #}
<option {% if use_dataset %}data-{% endif %}value="{{ widget.value|stringformat:'s' }}"{% include "django/forms/widgets/attrs.html" %}>{{ widget.label }}</option>

連動する要素として、Fieldも作成しておきましょう。

Datalist要素はSelect要素をベースにしたので、FieldもChoiceFieldModelChoiceFieldを参考に作成します。

Djangoは拡張しやすいように作られているため、元のコードがほぼ流用できます。

ゆると
ゆると

Datalist要素に対するFieldの定義は、以下に示す通りです。

# utils/widgets.py
class DatalistField(forms.ChoiceField):
  widget = Datalist

class ModelDatalistField(forms.ModelChoiceField):
  widget = Datalist

  def __init__(self, queryset, *, widget=None, **kwargs):
    widget = widget or self.widget
    super().__init__(queryset, widget=widget, **kwargs)

非常にシンプルな実装で済みましたね。

FormとTemplateの作成

先ほど作成したDatalist要素を利用するためのFormとTemplateを作成しますが、実装時の注意点が1つ存在します。

実装時の注意点1つ

外部キー(Foreign key)の設定にDatalist要素を用いる場合、formのsubmit時にinput要素のデータをdataset.valueに指定した値に置き換える必要がある。

図解すると、以下に様になります。

このような対応が必要になる理由として、モデルで外部キーを指定している場合、form送信時も外部キーを送信する必要があるためです。

上記の副作用を解決するために、今回は以下のような対応を取ることにしました。

副作用解消のための対応2つ

  1. Datalist要素を動的に作成する。
    Datalist要素に置き換えるfield名とFieldの引数をMetaクラスで定義する。
  2. submit時にJavascriptで該当要素の値を書き換える。
    事前にJavascriptを実装したテンプレートを用意し、呼び出し元でincludeする。

上記をプログラムで実装した結果は、以下のようになります。

# utils/forms.py
from django import forms
from .widgets import ModelDatalistField

class BaseDatalistModelForm(forms.ModelForm):
  template_name = 'renderer/utils_form.html'

  class Meta:
    #
    # Datalist要素を動的に追加するための設定
    #
    datalist_fields = []  # Datalist要素に置き換えるfield名一覧
    datalist_kwargs = {}  # 上記のfields名ごとの引数情報

  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    #
    # Datalist要素の設定
    #
    _meta = getattr(self, 'Meta', None)
    # Metaクラスから必要な情報を取得
    datalist_fields = getattr(_meta, 'datalist_fields', [])
    datalist_kwargs = getattr(_meta, 'datalist_kwargs', {})
    widgets = getattr(_meta, 'widgets', {})
    dynamic_fields = {}
    # 動的にフィールドを作成
    for field_name in datalist_fields:
      widget = widgets[field_name]
      options = datalist_kwargs[field_name]
      dynamic_fields[field_name] = ModelDatalistField(widget=widget, **options)
    # property作成時に必要になるため、明示的に保持
    self._extra_datalist_fields = [field_name for field_name in datalist_fields]
    # 関連する変数を更新
    self.declared_fields.update(dynamic_fields)
    self.fields.update(self.declared_fields)

  @property
  def datalist_js_template_name(self):
    return 'renderer/utils_datalist_javascript.html'

  @property
  def datalist_ids(self):
    # dataset.valueとして参照することにしたfieldのみを抽出
    related_fields = [
      self.fields[field_name] 
      for field_name in self._extra_datalist_fields if self.fields[field_name].widget.use_dataset()
    ]
    # 該当するHTML要素のidを取得
    datalist_ids = [field.widget.attrs['id'] for field in related_fields]

    return datalist_ids

また、関連するtemplateファイルは、以下のようになっています。

{# utils/templates/renderer/utils_form.html #}
{% load i18n %}

<div class="row row-cols-1 g-2">
  {% if form.non_field_errors %}
  <div class="col text-danger">
    <p class="fs-5">{% trans "Errors" %}</p>
    {{ form.non_field_errors }}
  </div>
  {% endif %}
  {% for field in form %}
  <div class="col">
    <div class="row">
      <div class="col-auto">
        <label for="{{ field.id_for_label }}" class="col-form-label">{{ field.label }}</label>
      </div>
      <div class="col-auto">
        {{ field }}
      </div>
      {% if field.help_text %}
      <div class="col-auto align-self-center helptext" {% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>
        {{ field.help_text|safe }}
      </div>
      {% endif %}
    </div>
    {% if field.errors %}
    <div class="row">
      <div class="col text-danger fs-6">
        {{ field.errors }}
      </div>
    </div>
    {% endif %}
  </div>
  {% if forloop.last and hidden_fields %}
  <div class="col">{% for field in hidden_fields %}{{ field }}{% endfor %}</div>
  {% endif %}
  {% endfor %}
</div>
{# utils/templates/renderer/utils_datalist_javascript.html #}
<script>
(function() {
  const ids = [
    {% for id_ in datalist_ids %}'{{ id_|stringformat:"s" }}',{% endfor %}
  ];
  const form = document.querySelector('#{{ form_id }}');

  form.addEventListener('submit', (event) => {
    // 関連するfieldの値を更新
    for (const id_ of ids) {
      const target = document.querySelector(`#${id_}`);
      const datalist = target.getAttribute('list');
      const options = document.querySelectorAll(`#${datalist} option`);
      const selectedOption = Array.from(options).find((option) => option.label === target.value);
      // 対応するオプションが見つかった場合は、書き換える
      if (selectedOption) {
        target.value = selectedOption.dataset.value;
      }
    }
  });
})();
</script>

以上で、準備は完了です。

サンプルアプリの実装

今回は、本の著者とタイトルを紐づけて登録する場合を想定し、著者名を選択する部分にDatalist要素を使う構成とします。

作り込む前に、下記のコマンドを実行し、bookアプリの作成・INSTALLED_APPSへの追加を実施しておきましょう。

# ホスト環境での操作
docker exec -it django.custom-form bash
# コンテナ内での操作
python manage.py startapp book
mkdir -p templates/book
cd book
touch forms.py urls.py
exit # or press Ctrl + D
# config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django.contrib.humanize',
    'django.forms',
    'book.apps.BookConfig', # <-- 追加
]

サンプルアプリの実装5ステップ

また、サンプルアプリの実装結果は、GitHubの以下のパスに格納しているので、あわせてご覧ください。

https://github.com/yuruto-free/implement-custom-form-using-django/tree/add-book-app(v0.1.2)

Modelの定義

今回は、以下のようなモデルを定義しました。

# book/models.py
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

class Author(models.Model):
  name = models.CharField(
    max_length=64,
    verbose_name=_('Name'),
    help_text=_('Max length of this field is 64.'),
  )

  def get_header(self):
    return [_('Name')]

  def get_members(self):
    return [self.name]

  def get_update_url(self):
    return reverse('book:update_author', kwargs={'pk': self.pk})

  def get_delete_url(self):
    return reverse('book:delete_author', kwargs={'pk': self.pk})

  def __str__(self):
    return self.name

class Book(models.Model):
  author = models.ForeignKey(
    Author,
    verbose_name=_('Author'),
    on_delete=models.CASCADE,
    related_name='books',
  )
  title = models.CharField(
    max_length=255,
    verbose_name=_('Title'),
    help_text=_('Max length of this field is 255.'),
  )

  def get_header(self):
    return [_('Author'), _('Title')]

  def get_members(self):
    return [self.author.name, self.title]

  def get_update_url(self):
    return reverse('book:update_book', kwargs={'pk': self.pk})

  def get_delete_url(self):
    return reverse('book:delete_book', kwargs={'pk': self.pk})

  def __str__(self):
    return f'{self.title}({self.author})'

テンプレートファイルの共有化のため、URL取得用のメソッドも追加しています。

ゆると
ゆると

Formの定義

Formの定義は以下のようになります。

# book/forms.py
from django import forms
from django.utils.translation import gettext_lazy as _
from utils.forms import BaseDatalistModelForm
from utils.widgets import Datalist
from . import models

class _BaseFormWithCSS(BaseDatalistModelForm):
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)

    for field in self.fields.values():
      _classes = field.widget.attrs.get('class', '')
      field.widget.attrs['class'] = f'{_classes} form-control'
      field.widget.attrs['placeholder'] = field.help_text

class AuthorForm(_BaseFormWithCSS):
  class Meta:
    model = models.Author
    fields = ('name',)

class BookForm(_BaseFormWithCSS):
  class Meta:
    model = models.Book
    fields = ('author', 'title')
    widgets = {
      'author': Datalist(attrs={
        'id': 'author-id',
        'use-dataset': True,
        'class': 'form-control',
      }),
    }
    # BaseDatalistModelForm
    datalist_fields = ['author']
    datalist_kwargs = {
      'author': {
        'label': _('Author'),
        'queryset': models.Author.objects.all(),
      },
    }

BookFormで定義しているように、Datalist要素を適用したい項目(今回は、author)に対し、datalist_fieldsdatalist_kwargsを設定します。

    # BaseDatalistModelForm
    datalist_fields = ['author']
    datalist_kwargs = {
      'author': {
        'label': _('Author'),
        'queryset': models.Author.objects.all(),
      },
    }

さらに、widgetの設定も必要になるため、従来通り設定を行います。

widgetは、先ほど作成したDatalistクラスを用いることに注意してください。

ゆると
ゆると

Viewの定義

Viewは、CreateViewとUpdateViewで使いまわしができるよう、共通クラスを定義しています。

# book/views.py
from django.views.generic import (
  ListView,
  CreateView,
  UpdateView,
  DeleteView,
)
from django.urls import reverse_lazy, reverse
from . import models, forms

# ==========
# For Author
# ==========
class ListAuthor(ListView):
  model = models.Author
  template_name = 'book/author_list.html'
  paginate_by = 10
  context_object_name = 'authors'

  def get_context_data(self, **kwargs):
    context = super().get_context_data(**kwargs)
    context['create_path'] = reverse('book:create_author')

    return context

class _CommonAuthorView:
  model = models.Author
  form_class = forms.AuthorForm
  template_name = 'book/author_form.html'
  success_url = reverse_lazy('book:list_author')

  def get_context_data(self, **kwargs):
    context = super().get_context_data(**kwargs)
    context['parent_path'] = self.success_url

    return context

class CreateAuthor(_CommonAuthorView, CreateView):
  pass

class UpdateAuthor(_CommonAuthorView, UpdateView):
  pass

class DeleteAuthor(DeleteView):
  raise_exception = True
  http_method_names = ['post']
  model = models.Author
  success_url = reverse_lazy('book:list_author')

# ========
# For Book
# ========
class ListBook(ListView):
  model = models.Book
  template_name = 'book/book_list.html'
  paginate_by = 10
  context_object_name = 'books'

  def get_context_data(self, **kwargs):
    context = super().get_context_data(**kwargs)
    context['create_path'] = reverse('book:create_book')

    return context

class _CommonBookView:
  model = models.Book
  form_class = forms.BookForm
  template_name = 'book/book_form.html'
  success_url = reverse_lazy('book:list_book')

  def get_context_data(self, **kwargs):
    context = super().get_context_data(**kwargs)
    context['parent_path'] = self.success_url

    return context

class CreateBook(_CommonBookView, CreateView):
  pass

class UpdateBook(_CommonBookView, UpdateView):
  pass

class DeleteBook(DeleteView):
  raise_exception = True
  http_method_names = ['post']
  model = models.Book
  success_url = reverse_lazy('book:list_book')

こちらは、Djangoの流儀に従って定義しています。

ゆると
ゆると

Templateの定義

テンプレートも似たようなものが多くなるため、共通化して定義しました。

base.html

{# templates/base.html #}
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE|stringformat:'s' }}">
  <head>
    {# Required meta tags #}
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no" />
    <meta http-equiv="x-ua-compatible" content="ie=edge" />
    <title>Custom Form using Django</title>
    {# Font Awesome #}
    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet" />
    {# Bootstrap 5.3 #}
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
    {% block header %}
    {% endblock %}
  </head>
  <body class="d-flex flex-column vh-100 bg-body-tertiary">
    <main class="w-100 mx-auto mb-auto">
      <div class="container">
      {# contents #}
      {% block content %}
      {% endblock %}
      </div>
    </main>
    {# Popperjs and Bootstrap 5.3 javascript #}
    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"  crossorigin="anonymous"
            integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.min.js" crossorigin="anonymous"
            integrity="sha384-fbbOQedDUMZZ5KreZpsbe1LCZPVmfTnH7ois6mU1QK+m14rQ1l2bGBq41eYeM/fS"></script>
    {% block bodyjs %}
    <script>
      const g_registerDeleteModalEvent = (args={}) => {
        const configs = {
          targetCSS:  args.hasOwnProperty('targetCSS')  ? args.targetCSS  : 'delete-target-record',
          formID:     args.hasOwnProperty('formID')     ? args.formID     : 'delete-form',
          modalID:    args.hasOwnProperty('modalID')    ? args.modalID    : 'delete-modal',
          targetName: args.hasOwnProperty('targetName') ? args.targetName : 'target-name',
        };
        const deleteBtns = document.querySelectorAll(`.${configs.targetCSS}`);
        const deleteForm = document.querySelector(`#${configs.formID}`);
        const targetField = document.querySelector(`#${configs.targetName}`);

        for (const btn of deleteBtns) {
          btn.addEventListener('click', (event) => {
            deleteForm.action = btn.dataset.url;
            targetField.textContent = btn.dataset.name;
            const modal = new bootstrap.Modal(`#${configs.modalID}`);
            modal.show();
          });
        }
      };
    </script>
    {% endblock %}
  </body>
</html>

index.html

{# templates/index.html #}
{% extends 'base.html' %}
{% load i18n %}

{% block content %}
<h2>{% trans "Index" %}</h2>
<div class="row justify-content-center">
  <div class="col">
    <div class="row row-cols-1 row-cols-md-2 g-2">
      <div class="col">
        <a href="{% url 'book:list_author' %}" class="link-underline link-underline-opacity-0">
          <div class="card">
            <div class="card-body">
              <p> class="card-title fs-3">{% trans "Author list" %}</p>
              <p> class="card-text">{% trans "Show authors." %}</p>
            </div>
          </div>
        </a>
      </div>
      <div class="col">
        <a href="{% url 'book:list_book' %}" class="link-underline link-underline-opacity-0">
          <div class="card">
            <div class="card-body">
              <p> class="card-title fs-3">{% trans "Book list" %}</p>
              <p> class="card-text">{% trans "Show books." %}</p>
            </div>
          </div>
        </a>
      </div>
    </div>
  </div>
</div>
{% endblock %}

list.html

{# templates/list.html #}
{% load i18n %}

<h2>{{ title }}</h2>
<div class="row justify-content-center">
  <div class="col">
    <div class="row row-cols-1 g-2">
      <div class="col">
        <div class="row">
          <div class="col-12 col-md-9">
            <a
              href="{{ create_path }}"
              class="btn btn-primary w-100"
            >
              {% trans "Create record" %}
            </a>
          </div>
          <div class="col-12 col-md-3">
            <a
              href="{% url 'index' %}"
              class="btn btn-secondary w-100"
            >
              {% trans "Back to top page" %}
            </a>
          </div>
        </div>
      </div>
      <div class="col">
        {% if objects %}
        <div class="table-responsive">
          <table class="table table-hover">
            <thead>
              <tr>
                {% with instance=objects|first %}
                {% for header_name in instance.get_header %}
                <th scope="col">{{ header_name }}</th>
                {% endfor %}
                {% endwith %}
                <th colspan="2">{% trans "Operate" %}</th>
              </tr>
            </thead>
            <tbody class="table-group-divider">
              {% for instance in objects %}
              <tr>
                {% for target in instance.get_members %}
                <td {% if forloop.first %}scope="row"{% endif %}>{{ target }}</td>
                {% endfor %}
                <td>
                  <a href="{{ instance.get_update_url }}" class="text-success link-underline link-underline-opacity-0">
                    <i class="fas fa-edit"></i>
                  </a>
                </td>
                <td>
                  <div
                    class="text-danger delete-target-record"
                    data-name="{{ instance|stringformat:'s' }}"
                    data-url="{{ instance.get_delete_url }}"
                  >
                    <i class="fas fa-trash"></i>
                  </div>
                </td>
              </tr>
              {% endfor %}
            </tbody>
          </table>
        </div>
        {% else %}
        <span>{% trans "There is no records. Please add records from the above button." %}</span>
        {% endif %}
      </div>
    </div>
  </div>
</div>

<div class="modal" id="delete-modal" tabindex="-1" aria-labelledby="deletion-label" aria-hidden="true">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <p class="modal-title fs-5" id="deletion-label">{% trans "Confirmation of deletion" %}</p>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        <p class="text-danger"><strong>{% trans "May I really delete this record?" %}</strong></p>
        <p>{% trans "target" %}: <span id="target-name" class="text-break"></span></p>
      </div>
      <div class="modal-footer">
        <form method="POST" action="" id="delete-form" class="w-100">
          {% csrf_token %}
          <div class="row g-2">
            <div class="col-12 col-md-6">
              <button type="submit" class="btn btn-danger w-100">
                {% trans "Accept" %}
              </button>
            </div>
            <div class="col-12 col-md-6">
              <button type="button" class="btn btn-secondary w-100" data-bs-dismiss="modal">
                {% trans "Close" %}
              </button>
            </div>
          </div>
        </form>
      </div>
    </div>
  </div>
</div>

form.html

{# templates/form.html #}
{% load i18n %}

<h2>{{ title }}</h2>
<div class="row justify-content-center">
  <div class="col">
    <div class="card">
      <div class="card-body">
        <form method="POST" id="{{ form_id }}">
          {% csrf_token %}

          {{ form }}

          <div class="mt-1 row row-cols-1 row-cols-md-2 g-2">
            <div class="col">
              <button>
                type="submit"
                class="btn btn-primary w-100"
              >
                {% trans "Register" %}
              </button>
            </div>
            <div class="col">
              <a>
                href="{{ parent_path }}"
                class="btn btn-secondary w-100"
              >
                {% trans "Cancel" %}
              </a>
            </div>
          </div>
        </form>
      </div>
    </div>
  </div>
</div>

author_list.html

{# templates/book/author_list.html #}
{% extends 'base.html' %}
{% load i18n %}

{% block content %}
{% include 'list.html' with title=_("Author list") create_path=create_path objects=authors %}
{% endblock %}

{% block bodyjs %}
<script>
(function () {
  document.addEventListener('DOMContentLoaded', g_registerDeleteModalEvent);
})();
</script>
{% endblock %}

author_form.html

{# templates/book/author_form.html #}
{% extends 'base.html' %}
{% load i18n %}

{% block content %}
{% include 'form.html' with title=_("Author form") form_id='author-form' form=form parent_path=parent_path %}
{% endblock %}

{% block bodyjs %}
{% endblock %}

book_list.html

{# templates/book/book_list.html #}
{% extends 'base.html' %}
{% load i18n %}

{% block content %}
{% include 'list.html' with title=_("Book list") create_path=create_path objects=books %}
{% endblock %}

{% block bodyjs %}
<script>
(function () {
  document.addEventListener('DOMContentLoaded', g_registerDeleteModalEvent);
})();
</script>
{% endblock %}

book_form.html

このテンプレートのみ、BaseDatalistModelFormで定義したメソッドを呼び出し、submit時に追加処理を行えるようにします。

{# templates/book/book_form.html #}
{% extends 'base.html' %}
{% load i18n %}

{% block content %}
{% include 'form.html' with title=_("Book form") form_id='book-form' form=form parent_path=parent_path %}
{% endblock %}

{% block bodyjs %}
{# =========== #}
{# = 追加部分 = #}
{% include form.datalist_js_template_name with form_id='book-form' datalist_ids=form.datalist_ids %}
{# =========== #}
{% endblock %}

URLの定義

configbookのそれぞれで、URLを定義すれば、実装は完了となります。

config/urls.py

# config/urls.py
from django.conf.urls.i18n import i18n_patterns
from django.contrib import admin
from django.urls import path, include
from django.views.generic import TemplateView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('i18n/', include('django.conf.urls.i18n')),
] + i18n_patterns(
    path('', TemplateView.as_view(template_name='index.html'), name='index'),
    path('book/', include('book.urls')),
)

book/urls.py

# book/urls.py
from django.urls import path
from django.views.generic import RedirectView
from . import views

app_name = 'book'

urlpatterns = [
  path('', RedirectView.as_view(pattern_name='index')),
  # Author
  path('list/authors', views.ListAuthor.as_view(), name='list_author'),
  path('create/author', views.CreateAuthor.as_view(), name='create_author'),
  path('update/author/', views.UpdateAuthor.as_view(), name='update_author'),
  path('delete/author/', views.DeleteAuthor.as_view(), name='delete_author'),
  # Book
  path('list/books', views.ListBook.as_view(), name='list_book'),
  path('create/book', views.CreateBook.as_view(), name='create_book'),
  path('update/book/', views.UpdateBook.as_view(), name='update_book'),
  path('delete/book/', views.DeleteBook.as_view(), name='delete_book'),
]

以上で、実装は完了となります。

動作確認

最後に、動作確認をしておきたいと思います。

まず、データベースのmigrationを行っておきましょう。

# ホスト環境での操作
docker exec -it django.custom-form bash
# コンテナ内での操作
python manage.py makemigrations
python manage.py migrate
exit # or press Ctrl + D

その後、Webブラウザで立ち上げたWebサーバにアクセスすると、以下のような画面が表示されると思います。

では、順番にアクセスしていき、動作確認を行いましょう。

Authorの追加

Indexページにて、「Author list」にアクセスすると、以下のような画面が表示されると思います。

画面上部の「Create record」を押下すると、下記のような画面に遷移することを確認してください。

後は、「Name」部分に著者名を記載し、「Register」を押下して、著者を追加していくだけです。

数名追加した結果は、以下のようになりました。

期待通り動作していますね。

Bookの追加

続いて、本題に関連するBook側の処理を確認していきます。

Indexページに戻り、「Book list」にアクセスすると、以下のような画面が表示されると思います。

先ほど同様に「Create record」を押下してください。

その後、「Author」のところにカーソルを当てると、先ほど追加した著者一覧が表示されると思います。

追加した著者のうち、適当な一名を選択して、テキストボックスに選択した対象が入力されていることを確認してください。

最後に、Titleを入力後、「Register」を押下します。

問題がなければ、下記のように「Author list」のページにrecordが追加されているはずです。

以上で、今回やりたかったことが実現できました。

改訂箇所

追加時の動作は期待通り動いていますが、修正時(update時)は、前回設定した値が参照されなくなっていると思います。

このような場合、utils/forms.pyでfieldを作成する際に初期値を設定する必要があるはずです。

fieldのinitial属性にデータを与えればよいはずですが、まだ検証まで出来ておりません。

ゆると
ゆると

修正処理を機能として実現したい場合は、この点の改善も必要になります。

まとめ

今回は、DjangoでDatalist要素を扱う方法について解説しました。

今回のポイント3つ

  1. 既存のWidgetとFieldを活用し、Datalist用のWidgetとFieldを作成する必要がある。
  2. Widgetを自作する場合は、templateの構成もあわせて検討が必要になる。
  3. 外部キー(Foreign key)のfieldをDatalist要素で代替する場合、form送信時に一工夫する必要がある。

Djangoは多機能なフレームワークですが、拡張しやすい構成になっているので、必要な機能がある場合は、ご自身で実現してみてはどうでしょうか。

スポンサードリンク

-プログラミング
-, , ,