前置き
この記事のつづきです。
「プロジェクト的単位」というのはタスク管理ツールなどで言う "プロジェクト" の事で、ここでは演劇の管理をするので「公演」がそれに当たります。
- -> GitHub のリポジトリ (Python 3.8.1, Django 3.0.3)
記事の内容
前回は「公演」と「公演のユーザ」を作り、アクセス制御をしました。
また、「公演のユーザ」がその公演に属するデータ (役者) を追加できるようにしました。
今回は、公演に属するデータを追加・編集する際のインターフェイスについて考えてみたいと思います。
具体的な目標
たとえば、「登場人物」というデータに「配役」という ForeignKey
フィールドを作って「役者」を参照する場合に、その公演の役者だけを選択可能にしたいです。
ところが、下のような Model
と View
を作るだけでは、cast
として全ての役者 (他の公演に属する役者も) が選べるようになってしまいます。
from django.db import models
class Character(models.Model):
'''登場人物
'''
production = models.ForeignKey(Production, verbose_name='公演',
on_delete=models.CASCADE)
name = models.CharField('役名', max_length=50)
cast = models.ForeignKey(Actor, verbose_name='配役',
on_delete=models.SET_NULL, blank=True, null=True)
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.edit import CreateView
from .models import Character
class ChrCreate(LoginRequiredMixin, CreateView):
'''Character の追加ビュー
'''
model = Character
fields = ('name', 'cast')
▲ 他の公演の役者が選べてしまう。 |
やりたい事の整理
ある "公演" に "登場人物" を追加することを考えます。
"登場人物" には "配役" というフィールドがあり、「その公演に属している "役者のデータ" を参照する」とします。
配役を選択する UI
上記の図版は「他の公演の役者が選べてしまう例」ですが、その問題を除けば UI はこれで良いです。
バリデーション
Web ブラウザから送られてくるリクエストが改ざんされている可能性を考えて、UI の選択肢だけでなく「受け入れる値」についても、「その公演の役者だけ」に限る必要があります。
あとで編集する時のこと
追加した "登場人物" をあとで編集する時のことを考えます。
せっかく「その公演の役者」を配役した登場人物の、所属先 (公演) を変更できてしまっては元のもくあみです。
一度作った "登場人物" の所属先は、変更できないようにしてしまうのが良さそうです。
UI の変更
このアプリではクラスベースのビュー (CreateView
) を使っていますので、フォームが自動的に生成され、テンプレートを以下のように書くことができます。
<form method="post">
{% csrf_token %}
<table>
{{ form }}
</table>
{% if object %}
<input type="submit" value="更新">
{% else %}
<input type="submit" value="追加">
{% endif %}
</form>
この {{ form }}
という部分にオブジェクトの各フィールドの入力 UI が展開されるので、ここを書き換えるという方法もあります。
その場合は cast
の選択肢を context
として渡し、テンプレート内でのフォームの書き方もフィールドごとにバラす必要があります。
ここではそれはやめて、{{ form }}
部分に展開されるフォームの内容を、あらかじめ改変するという方法をとります。
フォームの内容を改変する
フォームの内容を改変するには、フォームのサブクラスを作るというやり方もありますが、それをしなくても改変はできます。
ここではビューのメソッドの中で改変してみます。
フォームのサブクラス化については後で説明します。
フォームはビューからテンプレートに渡される context
の一種なので、get_context_data()
をオーバーライドして、以下のように改変することが可能です。
以下のコードでビューが継承している ProdBaseCreateView
は、公演 ID を受け取ってアクセス制御をする抽象クラスです (詳しくは前回の記事を御覧ください。今はあまり気にしなくて良いです)。
class ChrCreate(ProdBaseCreateView):
'''Character の追加ビュー
'''
model = Character
fields = ('name', 'cast')
def get_context_data(self, **kwargs):
'''テンプレートに渡すパラメタを改変する
'''
context = super().get_context_data(**kwargs)
# その公演の役者のみ表示するようにする
actors = Actor.objects.filter(production=self.production)
# 選択肢を作成
choices = [('', '---------')]
choices.extend([(a.id, str(a)) for a in actors])
# Form にセット
context['form'].fields['cast'].choices = choices
return context
詳しい解説
-
context['form']
で、ビューがテンプレートに渡そうとしているフォームを参照できます。 - フォームが持っているフィールドに
choices
を設定すると、それを選択肢として表示します。 -
choices
の各要素は、"値" と "表示文字列" のタプルになっています。 - このアプリでは
cast
に空をセットする事を許すので、一個目の選択肢の値を空にしてあります。
▲ 登場人物が属す公演の役者だけが表示された。 |
バリデーション
フォームのフィールドに choices
を設定すると自動的にそれ以外の値をはじいてくれる、という情報を見かけますが、実際にやってみると弾いてくれませんでした。
choices
を設定したフィールドが ForeignKey
だった事が原因かも知れません。
▲ ブラウザで選択肢を改ざんしてみる。 |
▲改ざんした値が追加できてしまった。 |
バリデーションの結果を改変する
ビューのメソッドをオーバーライドして、バリデーションの結果を改変する事ができます。
最終的にはフォームのサブクラスを作ってしまった方がすっきりしますが、まずはビューだけでやってみます。
class ChrCreate(ProdBaseCreateView):
# (中略)
def form_valid(self, form):
'''バリデーションを通った時
'''
# cast が同じ公演の役者でなければ、バリデーション失敗
instance = form.save(commit=False)
if instance.cast.production != self.production:
return self.form_invalid(form)
return super().form_valid(form)
form_valid()
は、フォーム側でバリデーションを通った時に呼ばれる、ビュー側の処理のメソッドです。
ここで強引に、バリデーションを通らなかった時に呼ばれるメソッド (form_invalid()
) を呼んでしまえば、保存を阻止することが出来ます。
詳しい解説
-
form.save(commit=False)
は、フォームが保存しようとしているオブジェクトを取得します。commit=True
にすると保存してから取得することになります。- 他にも
self.object
やform.instance
でもオブジェクトを取得できますが、違いが良く分かりません。ご存知の方がいましたら教えて頂けると嬉しいです。
- 他にも
-
if
文の中のself.production
は、このビューのスーパークラスProdBaseCreateView
が持っている属性で、保存しようとするオブジェクトの所属先の "公演" を持っています (詳しくは前回の記事を御覧ください)。
これの気持ち悪いところ
上のような form_valid()
から form_invalid()
を呼ぶ方法を気持ち悪いと思う方もいるかと思います。
form_valid()
の意味を「バリデーションを通った時に呼ばれて、続きの処理をするところ」と考えれば、そこでもバリデーションをするのは越権行為のような違和感があるからです。
結果として form_invalid()
が呼ばれるなど、そんなちゃぶ台返しが許されるのか、という感じです。
最悪のシナリオとしては、form_invalid()
からも form_valid()
を呼ぶことを許して、循環呼び出しが起こってしまう事も考えられます。
もちろん、そこは一方通行にする等ルールを決めておけば、上のやり方でも問題はありません。
もうひとつの方法 (フォームのサブクラス化)
フォームをサブクラス化するのであれば、「UI の変更」のところでやった get_context_data()
のオーバーライドも必要ありません。
代わりに、ビューでは get_form_kwargs()
というメソッドをオーバーライドします。
「テンプレートに渡すフォーム」を改変するのでなく、生成するフォーム自体をカスタマイズするイメージです。
フォームにデータを渡す
get_form_kwargs()
で取れる返り値は、フォームのコンストラクタに渡されるキーワード引数を辞書にしたものです。
なのでこれにエントリを追加すれば、フォーム側で使うことができます。
以下は、ChrForm
というクラスが定義されている前提での、get_form_kwargs()
のオーバーライドの例です。
ビューで form_class
を設定する場合は、fields
は設定しません (たしか設定するとエラーになったと思います)。
from rehearsal.forms import ChrForm
class ChrCreate(ProdBaseCreateView):
'''Character の追加ビュー
'''
model = Character
form_class = ChrForm
def get_form_kwargs(self):
'''フォームに渡す情報を改変する
'''
kwargs = super().get_form_kwargs()
# フォーム側でバリデーションに使うので production を渡す
kwargs['production'] = self.production
return kwargs
フォーム側の実装
from django import forms
from production.models import Production
from .models Character
class ChrForm(forms.ModelForm):
'''登場人物の追加・更新フォーム
'''
class Meta:
model = Character
fields = ('name', 'cast')
def __init__(self, *args, **kwargs):
# view で追加したパラメタを抜き取る
production = kwargs.pop('production')
super().__init__(*args, **kwargs)
# 配役は、同じ公演の役者のみ選択可能
queryset = Actor.objects.filter(production=production)
self.fields['cast'].queryset = queryset
cast
フィールドの choices
ではなく queryset
を設定しています。
こうすることで、設定したクエリセットに基づいて、選択肢の生成とバリデーションをするようになります。
このフォームを作っておけば、追加用のビューだけでなく、更新用のビューでも使えます。
これで、「バリデーション」のところでやった選択肢の改ざんをしてみます。
あとで編集する時のこと
"登場人物" や "役者" の所属先の "公演" を変えられないように、これらの追加/更新ビューで、production
の変更を禁止します。
編集可能フィールドの変更
Django で普通に model
を指定してフォームを作ると、すべてのフィールドが編集可能になってしまうので、編集できるフィールドを指定する必要があります。
- 「フォームのサブクラス化」でやったみたいに、ビューで
form_class
を指定する場合は、フォームのMeta
でfields
を指定します。 -
form_class
を指定しない場合は、ビューでfields
を指定します。
編集はしないが参照はしたい
編集しないフィールドも、フォームやテンプレートから参照したい場合があります。
いずれの場合も、これまでも触れてきたように、ビューの get_context_data()
や get_form_kwargs()
を使えば可能です。
まとめ
- 共に
production
(公演) というForeignKey
フィールドを持つ2種類のデータ ("登場人物" と "役者") を関連付ける時に、production
が同じになるようにする方法を考えました。 - ビューの方で UI とバリデーションの両方を改変することも出来ましたが、フォームをサブクラス化した方がすっきりとした実装になりました。
- ビューからテンプレートにデータを渡す場合は
get_context_data()
メソッドをオーバーライドしました。 - ビューからフォームにデータを渡す場合は
get_form_kwargs()
メソッドをオーバーライドしました。 - フォーム側で入力値の選択肢を指定するには、フィールドの
queryset
を設定すれば、UI とバリデーションの両方がそれに従いました。