0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Django でデータをプロジェクト的単位に分ける (2)

Last updated at Posted at 2019-11-30

前置き

この記事のつづきです。
「プロジェクト的単位」というのはタスク管理ツールなどで言う "プロジェクト" の事で、ここでは演劇の管理をするので「公演」がそれに当たります。

記事の内容

前回は「公演」と「公演のユーザ」を作り、アクセス制御をしました。
また、「公演のユーザ」がその公演に属するデータ (役者) を追加できるようにしました。

今回は、公演に属するデータを追加・編集する際のインターフェイスについて考えてみたいと思います。

具体的な目標

たとえば、「登場人物」というデータに「配役」という ForeignKey フィールドを作って「役者」を参照する場合に、その公演の役者だけを選択可能にしたいです。
ところが、下のような ModelView を作るだけでは、cast として全ての役者 (他の公演に属する役者も) が選べるようになってしまいます。

models.py
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)
views.py
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')
登場人物の追加.png
▲ 他の公演の役者が選べてしまう。

やりたい事の整理

ある "公演" に "登場人物" を追加することを考えます。
"登場人物" には "配役" というフィールドがあり、「その公演に属している "役者のデータ" を参照する」とします。

配役を選択する UI

上記の図版は「他の公演の役者が選べてしまう例」ですが、その問題を除けば UI はこれで良いです。

バリデーション

Web ブラウザから送られてくるリクエストが改ざんされている可能性を考えて、UI の選択肢だけでなく「受け入れる値」についても、「その公演の役者だけ」に限る必要があります。

あとで編集する時のこと

追加した "登場人物" をあとで編集する時のことを考えます。
せっかく「その公演の役者」を配役した登場人物の、所属先 (公演) を変更できてしまっては元のもくあみです。
一度作った "登場人物" の所属先は、変更できないようにしてしまうのが良さそうです。

UI の変更

このアプリではクラスベースのビュー (CreateView) を使っていますので、フォームが自動的に生成され、テンプレートを以下のように書くことができます。

modelname_form.html
<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 を受け取ってアクセス制御をする抽象クラスです (詳しくは前回の記事を御覧ください。今はあまり気にしなくて良いです)。

rehearsal/views/views.py
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 に空をセットする事を許すので、一個目の選択肢の値を空にしてあります。
登場人物の追加2.png
▲ 登場人物が属す公演の役者だけが表示された。

バリデーション

フォームのフィールドに choices を設定すると自動的にそれ以外の値をはじいてくれる、という情報を見かけますが、実際にやってみると弾いてくれませんでした。
choices を設定したフィールドが ForeignKey だった事が原因かも知れません。

改ざんする.png
▲ ブラウザで選択肢を改ざんしてみる。
追加できた.png
▲改ざんした値が追加できてしまった。

バリデーションの結果を改変する

ビューのメソッドをオーバーライドして、バリデーションの結果を改変する事ができます。
最終的にはフォームのサブクラスを作ってしまった方がすっきりしますが、まずはビューだけでやってみます。

rehearsal/views/views.py
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.objectform.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 は設定しません (たしか設定するとエラーになったと思います)。

rehearsal/views/views.py
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

フォーム側の実装

rehearsal/forms.py
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 を設定しています。
こうすることで、設定したクエリセットに基づいて、選択肢の生成とバリデーションをするようになります。

このフォームを作っておけば、追加用のビューだけでなく、更新用のビューでも使えます。

これで、「バリデーション」のところでやった選択肢の改ざんをしてみます。

はじかれた.png

あとで編集する時のこと

"登場人物" や "役者" の所属先の "公演" を変えられないように、これらの追加/更新ビューで、production の変更を禁止します。

編集可能フィールドの変更

Django で普通に model を指定してフォームを作ると、すべてのフィールドが編集可能になってしまうので、編集できるフィールドを指定する必要があります。

  • 「フォームのサブクラス化」でやったみたいに、ビューで form_class を指定する場合は、フォームの Metafields を指定します。
  • form_class を指定しない場合は、ビューで fields を指定します。

編集はしないが参照はしたい

編集しないフィールドも、フォームやテンプレートから参照したい場合があります。
いずれの場合も、これまでも触れてきたように、ビューの get_context_data()get_form_kwargs() を使えば可能です。

まとめ

  • 共に production (公演) という ForeignKey フィールドを持つ2種類のデータ ("登場人物" と "役者") を関連付ける時に、production が同じになるようにする方法を考えました。
  • ビューの方で UI とバリデーションの両方を改変することも出来ましたが、フォームをサブクラス化した方がすっきりとした実装になりました。
  • ビューからテンプレートにデータを渡す場合は get_context_data() メソッドをオーバーライドしました。
  • ビューからフォームにデータを渡す場合は get_form_kwargs() メソッドをオーバーライドしました。
  • フォーム側で入力値の選択肢を指定するには、フィールドの queryset を設定すれば、UI とバリデーションの両方がそれに従いました。
つづきを書きました
0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?