DjangoでCRUDシステムを作成していたときに、検索画面上に条件選択のチェックボックスを制御しようとして、とんでもない仕様上の罠にかかった記憶とその罠から抜け出した解決の備忘録です。
【使用バージョン】
- Server Rocky9(RedHat系)
- Python 3.9
- Django ver4
フォームを作成する
チェックボックスをDBテーブルから動的に作成するときにはMultipleChoiceFieldで作る方法とModelMultipleChoiceFieldで作る方法がありますが、正直なところ、後者はおすすめできません。なぜなら、query_setで設定したオブジェクトは特定の条件を満たさないとis_valid判定でfalseを返してしまうからで、その際に事前に無害化処理が必要になるからです(この解決方法は複雑すぎて、まだうまくいっていません)。
これがMultipleChoiceFieldの場合は問題なく処理してくれます。querysetと同様のセッティングも以下のように対応すれば問題ないです。ただし、required=False設定も忘れないで下さい。他のフォームモデルと違い、フォーム上でバリデーション表示はされないですが、これを忘れると後でエラーが発生します。
MultipleChoiceFieldで作る場合
MultipleChoiceFieldは複数の選択候補を制御するためのもので、widgetプロパティを指定しない場合はセレクトボックスとなります。また、choicesは任意の選択値をタプル化して格納するプロパティで、(選択された値、フォームに表示する値)となります。それからrequiredプロパティは必須で、この記述を忘れると値の処理ができません(他のフォーム部品のようにブラウザでバリデーション表示されないので、見落としがち)。
クエリの値を利用する場合は、values_list("valueとなる値","textとなる値")を用いると便利です。
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を使用します。
#著者マスタ
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) #出身地
テンプレートにチェックボックスを表示する
テンプレートにはこのように記述します。そのまま記述すれば直列になってしまいますが、ループ制御で表示させれば並列となります。
<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制御で取得します。そこで、このチェックボックスの値をセッションに代入する際にいろいろな方法を試してみましたが、次のうち、ページ遷移後もチェックボックスの値を正しく保持できるのはどれでしょうか?
- request.session['search'] = request.POST
- request.session['search'] = list(request.POST.values())
- request.session['search'] = dict(request.POST)
答えはいずれも不正解で、それぞれ困った問題を引き起こします。
1は、POSTそのままだとチェックボックスの値を受け付けないので選択の有無にかかわらず値はなかったことにして返されてしまいます。したがって、値を受け取るときに未定義キーのエラーが発生します。かといってjson化しようとしてもうまくはいきません。
2はフォームの値を全てプリミティブな値に変換してしまうために、チェックボックスで複数選択した場合、選択した最後尾の値しか保持しない現象が発生します。
3はディクショナリ化することで、複数選択も含め選択した場合は問題なく動いてくれます。しかし、無選択だった場合はsessionに値が代入されないので、、値を受け取るときに未定義キーのエラーが発生します。また、他のフォーム部品も一緒に存在した場合はテキストボックスなども勝手にオブジェクト化されてしまいます。
では、正解はどうするかというと……cleaned_dataで無害化されたオブジェクトを代入するです。つまり、以下のように記述します。
def search_author(request):
form = MstAuthorForm(request.POST)
if form.is_valid():
#任意の処理
request.session['search'] = form.cleaned_data
この方法がどこにも見つからなかったので、色々な開発系ブログや質問系サイトをヒントに、なんとか自力で解決方法を見つけました。
検索条件をクエリにかける場合
チェックボックスをクエリにかける場合はデータモデルのhoge__in= array
が便利です。チェックボックスの場合は次のような検索条件で、該当する項目を取得することができます。
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というフラグを記述すればタプルがリストになる)してやります。
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の場合は無害化したフォームデータを代入しておけば、同じメソッドでクエリ処理できます。
#前略
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は必須ではないようです。
birth_place = forms.ModelMultipleChoiceField(
queryset = MstPref.objects.all(), #allで取得する場合はうまくいく
label="MstAuthor",
widget=forms.CheckboxSelectMultiple, #これを記述しないとセレクトボックスになってしまう
)
また、このquerysetでフォームを作成した場合は、無害化処理したフォームをセッションに渡そうとするとjson絡みのエラーが発生する(動的プルダウンでテストしてみたら、エラー発生)ので、チェックボックスが絡んだ検索用フォームを作成する場合は使用しない方がいいような気がします。
付録:プルダウンを検索に用いる場合
ChoiceFieldやMultipleChoiceFieldは、widgetを設定しない場合はプルダウンやセレクトボックスになります。ですが、それらはチェックボックスと全く同じ記述だとある問題が発生します。
class SearchBookForm(forms.Form):
sel_auth = forms.ChoiceField(
choices= MstAuthor.objects.values_list('id','auth_nm').all(),
required = False,
)
これだと、プルダウンから未選択という選択肢がなくなってしまいます。そこで、BLANK_CHOICE_DASHというものを結合し、タプルをリスト化して連結するといいようですが…。
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より
この定数を採用すると、idがInt型などの場合はフォーム制御でエラーが起きてしまいます。なので、もうひと工夫が必要でした。sel_authが未入力の場合は全選択し、そうでない場合はオブジェクトに代入しておけば、プルダウンでもチェックボックスと同じような対応ができます。
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
関数一元化の理由
そもそも、なぜこのように回りくどいこと(フォームを無害化したものを処理用メソッドに代入)をしているかというと、チェックボックスが検索条件で与えられた場合は上記の方法でしかうまくいかなかったのと、それ以外のフォーム部品が付与されたときに、毎回引数の条件が異なると記述が煩雑になってくるからです。
実際に実装してみた検索システムではプルダウンのほかフリーワード検索対応のテキストボックスなども付与されており、こちらは別に無害化したものを転送しなくても問題はなかったのですが、チェックボックスは前述したように無害化したものでないと受け付けてくれないことがわかった以上、ほかもそれに合わせる必要があります。
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