NARITO BLOG

Django、選択した親カテゴリに合わせて子カテゴリを表示

Python, Django, HTML・CSS・JavaScript, Ajax,

概要

Django、選択した親カテゴリに合わせて子カテゴリを表示していきます。

親カテゴリを選択していないと、子カテゴリはすべて表示されています。

子カテゴリが全て表示されている

親カテゴリを選択すると、それに合わせて子カテゴリは絞り込まれます。

子カテゴリが絞り込まれている

データを作成する際や検索フォームでよく使います。

CreateViewの例

models.py

今回は以下のようなモデルの例です。記事にタイトルとカテゴリがあり、カテゴリは何らかの親カテゴリに属します。

from django.db import models


class ParentCategory(models.Model):
    name = models.CharField('親カテゴリ名', max_length=255)

    def __str__(self):
        return self.name


class Category(models.Model):
    name = models.CharField('カテゴリ名', max_length=255)
    parent = models.ForeignKey(ParentCategory, verbose_name='親カテゴリ', on_delete=models.PROTECT)

    def __str__(self):
        return self.name


class Post(models.Model):
    title = models.CharField('タイトル', max_length=255)
    category = models.ForeignKey(Category, verbose_name='カテゴリ', on_delete=models.PROTECT)

    def __str__(self):
        return self.title

forms.py

記事(Post)を作成するためのモデルフォームなのですが、少し追加があります。この追加は本筋とは関係がなく、ちょっとした利便性のために追加しただけのものです。 また、モデルフォームを使っていますが、通常のフォームでもやり方は大体同じになります。

from django import forms
from .models import Post, ParentCategory


class PostCreateForm(forms.ModelForm):
    # 親カテゴリの選択欄がないと絞り込めないので、定義する。
    parent_category = forms.ModelChoiceField(
        label='親カテゴリ',
        queryset=ParentCategory.objects,
        required=False
    )

    class Meta:
        model = Post
        fields = '__all__'

    field_order = ('title', 'parent_category', 'category')

親カテゴリの値によって子カテゴリを絞り込みますので、当然親カテゴリの選択欄はhtmlに表示されるべきです。とはいえ、モデルフォームを使っています...Postモデル自体には、親カテゴリはフィールドとして必要でしょうか。親カテゴリは子カテゴリからアクセスできます(post.category.parent)ので、必ずしも必要ではないでしょう。

モデルフォームを使いつつ、モデルとは直接関係のないフィールド....今回で言えば親カテゴリの選択欄となるフィールドが欲しいわけです。このような場合は、モデルフォームに通常のフィールドを新しく定義すれば解決します。この通常のフィールドは、モデル自体には影響を与えません。

長くなりましたが、それが以下の部分です。

    # 親カテゴリの選択欄がないと絞り込めないので、定義する。
    parent_category = forms.ModelChoiceField(
        label='親カテゴリ',
        queryset=ParentCategory.objects
    )

そして、フィールドの並び順指定です。 今回は{{ form.as_p }}での簡単な出力がしたかったので、並び順を指定しておかないと子カテゴリが先(上側)に表示されたりして違和感が出てきます。

field_order = ('title', 'parent_category', 'category')

views.py

単純なCreateViewですね。

class PostCreate(generic.CreateView):
    model = Post
    form_class = PostCreateForm
    success_url = '/'  # reverse_lazy等のほうが良い。これは手抜き

base.html

Bootstrap4なbase.htmlです。また、{% block extrajs %}を定義しています。

<!doctype html>
<html lang="ja">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">

    <title>親カテゴリを選択すると、子カテゴリが絞り込まれる</title>
  </head>
  <body>
    {% block content %}{% endblock %}

    <!-- Optional JavaScript -->
    <!-- jQuery first, then Popper.js, then Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
    {% block extrajs %}{% endblock %}
  </body>
</html>

Bootstrap4のStarter Templateとは少し違うので注意しましょう。公式のStarter TemplateではjQueryはスリム版を読み込んでいますが、スリム版はajaxメソッドを使えません。jQueryのajaxメソッドを使いたい場合は、スリムじゃないほうを読み込みます。

<script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>

他のテンプレートでJavaScriptを書きやすいように、{% block extrajs %}を定義します。

{% block extrajs %}{% endblock %}

post_form.html

こちらのテンプレートが今回のメインで、{% block extrajs %}内をどうやって実装するか?が今回の本筋です。

{% extends 'app/base.html' %}
{% block content %}
<form action="" method="POST">
    {{ form.as_p }}
    {% csrf_token %}
    <button type="submit">送信</button>
</form>
{% endblock %}

{% block extrajs %}
<script>
// ここを、どうやって実装する?
</script>
{% endblock %}

1. 全てのカテゴリを予め定義しておく

DjangoでJavaScriptのオブジェクトを動的に作成のアプローチと同じです。あらかじめJavascriptのオブジェクトとして全カテゴリを出力しておく方法です。 先にコードをお見せしましょう。

ビューでParentCategoryの一覧を取得し、それをテンプレートに渡すようにしておきます。

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['parent_list'] = ParentCategory.objects.all()
        return context

post_form.htmlの{% block extrajs %}内を実装します。

{% block extrajs %}
<script>
const categories = {
    {% for parent in parent_list %}
    '{{ parent.pk }}': [
        {% for category in parent.category_set.all %}
            {
                'pk': '{{ category.pk }}',
                'name': '{{ category.name }}'
            },
        {% endfor %}
    ],
    {% endfor %}
};

  $('#id_parent_category').on('change', () => {
    // 子カテゴリの選択欄を空にする。
    const categoryElement = $('#id_category');
    categoryElement.children().remove();

    // 選択した親カテゴリに紐づく子カテゴリの一覧を取得する。
    const parentId = $('#id_parent_category').val();
    const categoryList = categories[parentId];

    // 子カテゴリの選択肢を作成・追加。
    for(const category of categoryList) {
        const option = $('<option>');
        option.val(category['pk']);
        option.text(category['name']);
        categoryElement.append(option);
    }
  });

</script>
{% endblock %}

まずcategories変数ですが、これはJavaScriptのオブジェクトを動的に作成しています。 実際には、以下のような中身になります。'親カテゴリのpk': [子カテゴリ1, 子カテゴリ2] といった内容ですね。

const categories = {

    '3': [

            {
                'pk': '1',
                'name': 'サッカー'
            },

            {
                'pk': '2',
                'name': '野球'
            },

            {
                'pk': '3',
                'name': 'バスケ'
            },

    ],

    '4': [

            {
                'pk': '4',
                'name': '絵画'
            },

            {
                'pk': '5',
                'name': '彫刻'
            },

    ],

};

親カテゴリが選択されたらそのpkを取得し、紐づいている子カテゴリを選択欄に追加していきます。これが最初の方法です。

  $('#id_parent_category').on('change', () => {
    // 子カテゴリの選択欄を空にする。
    const categoryElement = $('#id_category');
    categoryElement.children().remove();

    // 選択した親カテゴリに紐づく子カテゴリの一覧を取得する。
    const parentId = $('#id_parent_category').val();
    const categoryList = categories[parentId];

    // 子カテゴリの選択肢を作成・追加。
    for(const category of categoryList) {
        const option = $('<option>');
        option.val(category['pk']);
        option.text(category['name']);
        categoryElement.append(option);
    }
  });

2. Ajaxを使う

Ajaxを使う方法でも実装してみます。今回の例ならばAjaxを使うまでもないような感じはしますが、場合によっては有効です。

jQueryのajaxメソッド

まずはよく見る、jQueryのajaxメソッドでやってみましょう。

post_form.htmlの{% block extrajs %}内を実装します。

{% block extrajs %}
<script>

  $('#id_parent_category').on('change', () => {
    // 子カテゴリの選択欄を空にする。
    const categoryElement = $('#id_category');
    categoryElement.children().remove();

    $.ajax({
        url:'{% url 'app:ajax_get_category' %}',
        type:'GET',
        data:{
            'pk':$('#id_parent_category').val(),
        }
    }).done( categoryList => {
        // 子カテゴリの選択肢を作成・追加。
        for(const category of categoryList) {
            const option = $('<option>');
            option.val(category['pk']);
            option.text(category['name']);
            categoryElement.append(option);
        }
    });
  });

</script>
{% endblock %}

Ajaxを使ってHTTPリクエストを送信しますので、それを受け付けるビューが必要です。ビューは以下のようにしました。

def ajax_get_category(request):
    pk = request.GET.get('pk')
    # pkパラメータがない、もしくはpk=空文字列だった場合は全カテゴリを返しておく。
    if not pk:
        category_list = Category.objects.all()

    # pkがあれば、そのpkでカテゴリを絞り込む
    else:
        category_list = Category.objects.filter(parent__pk=pk)

    # [ {'name': 'サッカー', 'pk': '3'}, {...}, {...} ] という感じのリストになる。
    category_list = [{'pk': category.pk, 'name': category.name} for category in category_list]

    # JSONで返す。値が辞書じゃない場合は、safe=Falseが必要。今回はリストなのでsafe=Falseに。
    return JsonResponse(category_list, safe=False)

もちろん、urls.pyにも記述が必要です。

path('ajax/category/', views.ajax_get_category, name='ajax_get_category')

※今回はGETメソッドで送信するので大丈夫ですが、POSTメソッドで送信する場合はCSRFトークン対策が必要です。ビューに@csrf_exemptデコレータをつけたり、Djangoで、Ajaxで行ったような対策をしましょう。

Fetch API

jQueryを使わない場合の選択肢で、新しめなブラウザに対応できてりゃいいよ、ということならFetch APIも使えます。

{% block extrajs %}
<script>
    const parentCategoryElement = document.getElementById('id_parent_category');
    const categoryElement = document.getElementById('id_category');

    // 親カテゴリが変更されたら
    parentCategoryElement.addEventListener('change', () => {
        categoryElement.innerHTML = '';
        const pk = parentCategoryElement.options[parentCategoryElement.selectedIndex].value;
        const url = `{% url 'app:ajax_get_category' %}?pk=${pk}`;
        fetch(url)
            .then(response => {
                return response.json();
            })
            .then(categoryList => {
                for (const category of categoryList) {
                    const option = document.createElement('option');
                    option.value = category.pk;
                    option.innerHTML = category.name;
                    categoryElement.add(option);
                }
            })
            .catch(error => {
                console.log(error)
            });
    });
</script>
{% endblock %}

検索フォームの例

今までの例はデータを作成するサンプルでしたが、検索フォームでもこのような機能はよく使います。やってみましょう。

forms.pyを例えば次のようにします。

class PostSearchForm(forms.Form):
    """記事検索フォーム。"""

    parent_category = forms.ModelChoiceField(
        label='親カテゴリ',
        required=False,
        queryset=ParentCategory.objects,
    )

    category = forms.ModelChoiceField(
        label='子カテゴリ',
        required=False,
        queryset=Category.objects,
    )

    paginate_by = forms.IntegerField(
        label='1ページの表示件数',
        required=False,
        help_text='10や20といった数値を入力してください'
    )

次に、views.pyです。

class PostList(generic.ListView):
    model = Post

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

    def get_queryset(self):
        queryset = super().get_queryset()
        form = self.form = PostSearchForm(self.request.GET)
        form.is_valid()

        parent_category = form.cleaned_data.get('parent_category')
        category = form.cleaned_data.get('category')
        paginate_by = form.cleaned_data.get('paginate_by')

        # 親カテゴリを選択していれば、それで絞り込む
        if parent_category:
            queryset = queryset.filter(category__parent=parent_category)

        # 子カテゴリを選択していれば、それで絞り込む
        if category:
            queryset = queryset.filter(category=category)

        # 表示件数の指定があれば
        if paginate_by and paginate_by > 0:
            self.paginate_by = paginate_by

        return queryset

ちょっと長くなりましたが、やってることは簡単です。テンプレートにフォームを渡すためにget_context_data()を上書きするのと、get_queryset()内では、選択された親カテゴリや子カテゴリの値でfilter()を呼び出し、絞り込みます。

get_queryset()内で既にフォームを作成しているので、その作成済みフォームを再利用するようにしています。get_queryset()内でform = self.form = TagSearchForm(self.request.GET)としていますね。

ちなみにですが、get_queryset()内でself.paginate_by = 一度に表示したい件数といった書き方で表示件数をコントロールすることもできます。便利ですね。

検索フォームのようなものだと、不正な入力値は単純に無視するだけ、といったこともよく行います。なので、よくあるif form.is_valid():のようにして入力が正常だった場合・そうでない場合の処理を分けていません。他のフィールドでの絞り込みを引き続き行います。

とはいえ、cleaned_data辞書からデータを取り出すためにはform.is_valid()の呼び出し自体は必要なので書く必要があります。

is_valid()によってフォームにcleaned_data辞書が作られ、各フィールドのヴァリデーション処理が正常に終わればcleaned_data[フィールド名] = 入力値としてセットされていきます。そのフィールドが不正な値だった場合、ヴァリデーションに失敗した場合は辞書にセットされないという流れです。

post_list.htmlは次のようになります。シンプルですね。

{% extends 'app/base.html' %}
{% block content %}
<form action="" method="GET">
    {{ form.as_p }}
    <button type="submit">検索</button>
</form>

{% for post in post_list %}
    <p>{{ post }}</p>
{% endfor %}
{% endblock %}

{% block extrajs %}
今までと同じ
{% endblock %}

フォーム内容を引き継ぐ場合の注意点

フォーム内容を次ページに引き継ぐ場合は少し注意があります。例えば親カテゴリにスポーツ、子カテゴリにサッカーを選択し、検索したとします。

検索結果を新しいページで表示させますが、親カテゴリにスポーツ、子カテゴリにサッカーが既に入っている状態にしたいかもしれません。この動作自体は、上のコードのようにPostSearchForm(request.GET)のようにしたものをテンプレートへ渡すだけで済みます。

しかし、そのままだと子カテゴリは全て表示されます。子カテゴリはサッカー、野球、バスケといった項目に既に絞り込まれた状態が理想です。

これは検索フォームだけでなく、CreateView等でのデータ作成時にも起きます。フォームの入力内容に問題があった場合、CreateViewはもう一度入力画面を表示しますが、その際に子カテゴリ欄は全ての子カテゴリが選択肢として選べます。

解決方法はいくつかあります。例えばJavaScriptで、親カテゴリが変更された場合の処理を関数にでも分割して、ページ表示時にその関数を一度呼び出す、というのもよくあります。

Django側で対処するならば、forms.pyで次のようにしておくと良いでしょう。

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        category = self.fields['child_category']

        # 子カテゴリ欄の設定
        # GETパラメータ内に親カテゴリの指定があれば、それに属する子カテゴリをfilterで取得
        parent_category_pk = self.data.get('parent_category')
        if parent_category_pk:
            category.queryset = category.queryset.filter(
                parent=parent_category_pk
            )