Djangoでのフォーム制御要注意ポイント

DjangoでCRUDシステムを作成していたときに、検索画面上に条件選択を制御しようとして、とんでもない仕様上の罠にかかった記憶とその罠から抜け出した解決の備忘録です。

【使用バージョン】
- Server Rocky9(RedHat系)
- Python 3.9
- Django　ver4

その前に、基本のおさらいですが、フォームはCRUDと呼ばれるうちのcreate（登録）、Update（修正）に使われるほかにも、検索でも大いに使用されます。

## 検索用のフォームを作るときの最大の注意点
検索の場合は登録、修正のときと大きく性質が異なります。それは、フォームの入力必須条件を消しておかないと検索が機能しない、すなわち**フォームモデルでrequired=Falseを設定しておく**必要があることです。

# チェックボックス（複数制御）
正直、チェックボックスほど厄介な部品はありません。複数選択を前提としたチェックボックスでも、後述する単一のチェックボックスでも、仕様上の落とし穴が山と潜んでいます。

チェックボックスをDBテーブルから動的に作成するときには**MultipleChoiceField**で作る方法と**ModelMultipleChoiceField**で作る方法がありますが、正直なところ、後者はおすすめできません。なぜなら、query_setで設定したオブジェクトは特定の条件を満たさないとis_valid判定でfalseを返してしまうからで、その際に事前に無害化処理が必要になるからです（この解決方法は複雑すぎて、まだうまくいっていません）。

これがMultipleChoiceFieldの場合は問題なく処理してくれます。querysetと同様のセッティングも以下のように対応すれば問題ないです。ただし、required=False設定も忘れないで下さい。他のフォームモデルと違い、フォーム上でバリデーション表示はされないですが、これを忘れると後でエラーが発生します。

## MultipleChoiceFieldで作る場合
MultipleChoiceFieldは複数の選択候補を制御するためのもので、widgetプロパティを指定しない場合はセレクトボックスとなります。また、choicesは任意の選択値をタプル化して格納するプロパティで、(選択された値、フォームに表示する値)となります。それからrequiredプロパティは必須で、この記述を忘れると値の処理ができません（他のフォーム部品のようにブラウザでバリデーション表示されないので、見落としがち）。

クエリの値を利用する場合は、values_list("valueとなる値","textとなる値")を用いると便利です。

```py:forms.py
from django import forms

class SearchAuthorForm(forms.Form):
  birth_place = forms.MultipleChoiceField(
    choices = MstAuthor.objects.values_list('place','place').distinct(), #タプル型にする
    widget=forms.CheckboxSelectMultiple, #これを記述しないとセレクトボックスになってしまう
    required=False, #これを忘れるとうまく動かない
  )
```

### 任意のカラムから選択データを作成
MstAuthorは任意で作成したDBテーブルで、著者に関するマスタ情報が格納されています。今回はその中のカラムの一つplace(出身地)を活用します。この出身地は重複した値もあるので、クエリオブジェクトを取得する際にdistinctを使用します。

```py:models.py
#著者マスタ
class MstAuthor(models.Model):
  auth_nm = models.CharField('auth_nm', max_length=30) #著者名
  auth_yomi = models.CharField('auth_yomi', max_length=30) #著者カナ
  birth = models.DateField('birth', max_length=30,blank=True,null=True) #生年月日
  end = models.DateField('end', max_length=30,blank=True,null=True) #没年月日
  place = models.CharField('place', max_length=20,blank=True,null=True) #出身地
```

## テンプレートにチェックボックスを表示する
テンプレートにはこのように記述します。そのまま記述すれば直列になってしまいますが、ループ制御で表示させれば並列となります。

```html:list_author.html
  <dl class="dl_search">
    <dt>出身地</dt><dd>
      {% for choice in search_authorform.birth_place %}
        {{ choice.choice_label }}
        {{ choice.tag }}
      {% endfor %}
    </dd>
  <dt>年代</dt>
```


これで普通にrequest.POSTを返すだけなら問題なく動きます。ですが、ここから更に泥沼にはまってしまったのが、ページャーで画面を遷移させた場合です。

## 値をセッションで保持する
ページャーでページ遷移させる場合に検索値を保持するにはsessionを利用するのがセオリーで、これをGET制御で取得します。そこで、このチェックボックスの値をセッションに代入する際にいろいろな方法を試してみましたが、次のうち、ページ遷移後もチェックボックスの値を正しく保持できるのはどれでしょうか？

1. request.session['search'] = **request.POST**
1. request.session['search'] = **list(request.POST.values())**
1. request.session['search'] = **dict(request.POST)**

答えは**いずれも不正解**で、それぞれ困った問題を引き起こします。

1は、POSTそのままだと**チェックボックスの値を受け付けないので**選択の有無にかかわらず値はなかったことにして返されてしまいます。したがって、値を受け取るときに未定義キーのエラーが発生します。かといってjson化しようとしてもうまくはいきません。

2はフォームの値を全てプリミティブな値に変換してしまうために、**チェックボックスで複数選択した場合、選択した最後尾の値しか保持しない**現象が発生します。

3はディクショナリ化することで、複数選択も含め選択した場合は問題なく動いてくれます。しかし、**無選択だった場合はsessionに値が代入されない**ので、、値を受け取るときに未定義キーのエラーが発生します。また、他のフォーム部品も一緒に存在した場合はテキストボックスなども勝手にオブジェクト化されてしまいます。

では、正解はどうするかというと……**cleaned_dataで無害化されたオブジェクトを代入する**です。つまり、以下のように記述します。

```py:views.py
def search_author(request):
    form = MstAuthorForm(request.POST)
    if form.is_valid():
        #任意の処理
        request.session['search'] = form.cleaned_data
```

この方法がどこにも見つからなかったので、色々な開発系ブログや質問系サイトをヒントに、なんとか自力で解決方法を見つけました。

## 検索条件をクエリにかける場合
チェックボックスをクエリにかける場合はデータモデルの`hoge__in= array`が便利です。チェックボックスの場合は次のような検索条件で、該当する項目を取得することができます。

```py:views.py
    birth_place = form.cleaned_data['birth_place']
    rows_result = MstAuthor.objects.filter(
      place__in = birth_place,
    ).order_by("id")
```

ところが、これだとチェックボックスで無選択だった場合、placeの値が存在しないデータを抽出する制御をしてしまい、全テーブルにplaceのデータが存在した場合は何も検索に引っかからなくなってしまいます。なので、**無選択の場合は全選択させるという条件を付与する**必要があります（ここが冒頭で述べた見落としがちな部分）。そのとき、大きく手がかりになるのが、先程のMultipleChoiceFieldのchoicesプロパティで設定した任意のオブジェクトを全取得する方法で、それを**リスト化**（values_listの中にflat=Trueというフラグを記述すればタプルがリストになる）してやります。

```py:functions.py
from app.models import MstAuthor
class Search():
  def author(self,form):
    all = MstAuthor.objects.values_list('place',flat=True).distinct() #リスト化する
    birth_place = form.cleaned_data['birth_place'] or all #条件選択か全選択となる
    rows_result = MstAuthor.objects.filter(
      place__in = birth_place,
    ).order_by("id")
    return rows_result

```

こうすれば、検索条件は一部の条件か全選択でクエリ操作ができます。ちなみに、sessionの値をフォームに保持したままにする場合は先程のsessionの値をそのまま代入すれば大丈夫です(is_validによるチェックは不要)。また、POSTの場合は無害化したフォームデータを代入しておけば、同じメソッドでクエリ処理できます。

```py:views.py
#前略
from app.models import MstAuthor
from app.forms import SearchAuthorForm
from app.functions import Func,Search

#中略

def search_author(request):
 fn = Func() #処理用メソッド（今回は説明割愛）
 search = Search() #検索クラス
 method = request.method
 #検索ボタンを押下した場合
 if method == "POST":
   form = SearchAuthorForm(request.POST on None)
   if form.is_valid():
 　   rows_result = search.author(form.cleaned_data) #検索処理
      request.session['search'] = form.cleaned_data #無害化されたフォームの値を代入
 #ページ遷移した場合
 elif method == "GET":
   if request.GET.get('page'):
      ses_data = request.session.get('search') #cleaned_dataで処理された値をsessionから取得
      rows_result = search.author(ses_data) #検索処理
      form = SearchAuthorForm(ses_data) #フォームオブジェクトにsession値を代入
 else:
   form = SearchAuthorForm()
   rows_result = MstAuthor.objects.all().order_by("id")
 [pages,datalist] = fn.set_page(request,rows_result,5) #ページャー用の制御関数(今回は説明割愛)

  return render(request,'search/list_author.html',{
    'result':rows_result, #検索結果
    'datalist':datalist, #表示対象のデータ
    'pages':pages, #ページャー制御のオブジェクト
    'search_authorform':form, #フォームオブジェクト     
  })
```
## ModelMultipleChoiceFieldで作る場合
参考までにModelMultipleChoiceFieldは以下のようになり、こちらはクエリオブジェクトをそのまま活用します。ただし、is_validでfalseにならない条件として、querysetに対しall()で返す必要があるようで、上記のように任意モデルの特定カラムだけを抽出した場合は、どうもis_valid判定で引っかかるようです。ちなみに、こっちにおいては、required=Falseは必須ではないようです。

```py:forms.py
  birth_place = forms.ModelMultipleChoiceField(
    queryset = MstPref.objects.all(), #allで取得する場合はうまくいく
    label="MstAuthor",
    widget=forms.CheckboxSelectMultiple, #これを記述しないとセレクトボックスになってしまう
  )
```

また、このquerysetでフォームを作成した場合は、無害化処理したフォームをセッションに渡そうとするとjson絡みのエラーが発生する（動的プルダウンでテストしてみたら、エラー発生）ので、チェックボックスが絡んだ検索用フォームを作成する場合は使用しない方がいいような気がします。

## 単一のチェックボックスで制御
単一のチェックボックスで制御する場合にも色々と注意が必要で、仕様上の落とし穴だらけです。こんなことを招いてしまう原因はDjangoにおけるチェックボックスの仕様と、バリデーション、DBテーブルモデル、フォームモデル、DBMSでbool処理に対して食い違いが発生するからです。

### 注意点１（チェックボックスはBoolean型）
最初に引っかかりやすいポイントとして、単一のチェックボックスで処理する場合は、**フォームモデルはCharFieldではなく、BooleanField**にしないといけません。逆にBooleanFieldにすると、ウィジェット設定をしなくても、自動でチェックボックスとなります。

```py:forms.py
#読書済フラグを作る
class ReadForm(forms.ModelForm)
    read_flg =  forms.BooleanField() #これでチェックボックスとなる
```

ここで、チェックボックスはチェックが入るとPOSTの値に**on**が代入され、チェックが解除されるとPOSTの値は代入されません。この仕様を把握しておくことが大事です。

### 注意点２（request.POSTの仕様）
前述したように**チェックボックスは単一であっても、一旦はリストオブジェクトに代入**される仕様となっています。したがってチェックを入れた場合['on']という状態で代入されるのですが、いざis_validでif判定すると、エラーログに「この値は1文字以下でないといけません」という警告が表示され、判定はエラーを返すことが多々あります。

※フォームモデルに対しerrosプロパティを追記すると、具体的なバリデーションが表示されます。

```py:py
			if(book_form.is_valid()):
                #中略
            else:
                print(book_form.errors) #エラーを確認するためのコマンド

```

なぜ、そんな想定外のエラーが表示されてしまうのか？　その理由は概ね、**盲点ともいうべき設定上の人的ミス**によるものです。

### 注意点３（テーブルモデルとの連携）
フォームモデル（forms.py）はDBテーブルモデル（models.py）上から、メタクラスを使用して作成することが多いと思います。そしてDBテーブルのカラム枠も真偽判定、すなわちOか1を代入するためにmax_length=1としてしまいがちですが…。

is_valid関数はフォームモデル、すなわちメタクラス化したDBテーブル通りかをチェックするため**チェックボックス側のオブジェクト及びクォート分が余剰となってしまう**のです。

具体的にいえば文字要素が1文字だった場合、

<strong>文字要素(1) + 文字を囲う左右クォート(2) + リストオブジェクトの左右括弧(2)</strong>

と最低、5文字分必要、つまりmax_length=5とする必要があります。

### 注意点４（一部DBの場合）
is_validを通過したチェックボックスの値はTrueとなって評価されます。しかし、sqliteやmysqlの場合はbool型というものは存在せず、mysqlでは代替の方法としてtinyint型（つまり0か1）で対応します。ですが、Trueという値を代入できないので、更新作業の場合はコミットする前に、0や1といった値の再代入処置が必要となります。

また、チェックボックスにチェックが入っていない場合、request.POSTには何も値が存在しないので、三項演算子でのif判定も 'xxxx' in request.POSTとしないとエラーとなります。

```py:py
            f = request.POST
            f._mutable = True #イミュータブルの解除
			f['read_flg'] =  1 i'read_flg' in requet.POST else 0 #is_validを通過できるように再代入
			f._mutable = False
			if(book_form.is_valid()):
				ps = books_form.save(commit=False) #まだ、コミットはしない
				ps.oss_flg = f['read_flg'] = #そのままだとTrueになっているので、再代入した値に設定する
				ps.save()

```

つまり、フォームモデルではOnという値で判定、でもvalueで通るには一度リスト化されてしまい、しかもis_valid判定を通過すると、Trueという値に変わってしまう、特殊な仕様が盛り込まれているので、DBに登録する際には注意が必要なわけです。


### 疑似要素でトグルボタンを用いる場合
また、この単一チェックボックスは、昨今ではトグルボタンに置き換えることも多いと思いますが、トグルボタンで作成する場合は直書きが必要で、フォームモデルからテンプレートに呼び出しても機能しません（CSS側で疑似要素が作成されていないため）。
```html: list_read.html
				<label class="toggle-switch">
					<input type="checkbox" class="cbox" name="read_flg" id="read_flg" 
					{% if books_form.read_flg %}
					checked
					{% endif %}
					/>
				<span class="slider"></span>
				</label>
```

このように、明示的にcheckの付け足しをしましょう。


# プルダウン
ChoiceFieldやMultipleChoiceFieldは、widgetを設定しない場合はプルダウンやセレクトボックスになります。ですが、それらはチェックボックスと全く同じ記述だとある問題が発生します。

```py:forms.py
class SearchBookForm(forms.Form):
  sel_auth = forms.ChoiceField(
    choices= MstAuthor.objects.values_list('id','auth_nm').all(),
    required = False,
  )
```

これだと、プルダウンから未選択という選択肢がなくなってしまいます。そこで、BLANK_CHOICE_DASHというものを結合し、タプルをリスト化して連結するといいようですが…。

```py:forms.py
from django.db.models import BLANK_CHOICE_DASH
class SearchBookForm(forms.Form):
  b_word = forms.CharField(max_length=50,label="キーワード",required=False)
  sel_auth = forms.ChoiceField(
    choices= BLANK_CHOICE_DASH + list(MstAuthor.objects.values_list('id','auth_nm').all()),
    required = False,
  )
```
- Stack Overflowより

https://stackoverflow.com/questions/1765757/choicefield-doesnt-display-an-empty-label-when-using-a-tuple

この定数を採用すると、idがInt型などの場合はフォーム制御でエラーが起きてしまいます。なので、もうひと工夫が必要でした。sel_authが未入力の場合は全選択し、そうでない場合はオブジェクトに代入しておけば、プルダウンでもチェックボックスと同じような対応ができます。

```py:functions.py
class Search():
  def books(self,form):
    all = MstAuthor.objects.all()
    sel_auth = all if form['sel_auth'] is '' else [form['sel_auth']]
    rows_result = MstBooks.objects.select_related('auth_no').filter(
      auth_no__in = sel_auth,
    ).order_by("id")
    print(rows_result.query)
    return rows_result
```

## 任意のメッセージを初期に盛り込む場合
プルダウンはよく「………選択してください……」と任意のメッセージを記述することがありますが、その場合はBLANK_CHOISE_DASHの代わりにタプルをオブジェクトに用意し、データモデルと連結します。

```py:forms.py
from django.db.models import BLANK_CHOICE_DASH
class SearchBookForm(forms.Form):
  b_word = forms.CharField(max_length=50,label="キーワード",required=False)
  sel_auth = forms.ChoiceField(
    choices= [(None,"---選択してください---")] + list(MstAuthor.objects.values_list('id','auth_nm').all()),
    required = False,
  )
```

## 関数一元化の理由
そもそも、なぜこのように回りくどいこと（フォームを無害化したものを処理用メソッドに代入）をしているかというと、チェックボックスが検索条件で与えられた場合は上記の方法でしかうまくいかなかったのと、それ以外のフォーム部品が付与されたときに、毎回引数の条件が異なると記述が煩雑になってくるからです。

実際に実装してみた検索システムではプルダウンのほかフリーワード検索対応のテキストボックスなども付与されており、こちらは別に無害化したものを転送しなくても問題はなかったのですが、チェックボックスは前述したように無害化したものでないと受け付けてくれないことがわかった以上、ほかもそれに合わせる必要があります。

```py.function.py
from functools import reduce
from operator import and_

class Search():
  def books(self,form):
    all = MstAuthor.objects.all()
    b_word = form['b_word']
    if re.search("\s", b_word): #複数キーワード
      words = b_word.split()
    elif b_word != '':
      words = [b_word] #単数キーワード
    else:
      words = [""] #未入力
    sel_auth = all if form['sel_auth'] is '' else [form['sel_auth']]
    rows_result = MstBooks.objects.select_related('auth_no').filter(
      reduce(and_,[Q(book_nm__icontains=q) for q in words]),
      auth_no__in = sel_auth,
    ).order_by("id")
    print(rows_result.query)
    return rows_result

  def author(self,form):
    all = MstAuthor.objects.values_list('place',flat=True).distinct()
    a_word = form['a_word']
    birth_place = form['birth_place'] or all
    s_year = form['start_year']
    e_year = form['end_year']
    s_year = '1800-01-01' if s_year is None else f'{s_year}-01-01'
    e_year = '2100-12-31' if e_year is None else f'{e_year}-12-31'
    rows_result = MstAuthor.objects.filter(
      auth_nm__contains=a_word,
      place__in = birth_place,
      birth__gte = s_year,
      birth__lte = e_year,
    ).order_by("id")
    return rows_result

```

※検索条件にしたがってfilterメソッドのプロパティを自動追加していく方法もあります。