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

/ PythonDjangoJavaScriptAjax

15日前に更新

概要

Djangoで、選択した大カテゴリによって、小カテゴリの内容を絞りこむような例を解説していきます。

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

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

大カテゴリ・小カテゴリのような作りにしている場合、この自動絞り込み機能があると非常に便利です。今回は作成処理...CreateViewモデルフォームを使う例ですが、検索処理などの場合も同様の感じで実装できます。

ソースコードのダウンロード

Githubからダウンロードしてください。

前提モデル

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

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

フォームを作る

記事(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に親カテゴリのフィールドも持たせてしまえば良いのでは?と思うかもしれませんが、子カテゴリさえ持たせておけばpost.category.parentで記事から親カテゴリにアクセスできますし、持たせちゃうと記事のカテゴリ変更時に処理が増えてしまうので、オススメしません。

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

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

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

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

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

ビューを作る

単純なCreateViewで実装しておきます。

from django.views import generic
from .forms import PostCreateForm
from .models import Post


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

urls.pyで、上のビューとURLを紐づけておきましょう。

テンプレートファイル

今回はBootstrap4を利用します。まず、共通テンプレートとなるbase.htmlです。

<!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を作ります。

{% 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 %}

{% block extrajs %}内にカテゴリを絞り込むためのjsコードを書くのですが、方法が大雑把に2つあります。

全てのカテゴリを予め定義しておく方法

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

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

from django.views import generic
from .forms import PostCreateForm
from .models import Post, ParentCategory


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

    # これが追加
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['parentcategory_list'] = ParentCategory.objects.all()
        return context

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

{% block extrajs %}
    <script>
        const parentCategoryElement = $('#id_parent_category');
        const categoryElement = $('#id_category');
        const categories = {
            {% for parent in parentcategory_list %}
                '{{ parent.pk }}': [
                    {% for category in parent.category_set.all %}
                        {
                            'pk': '{{ category.pk }}',
                            'name': '{{ category.name }}'
                        },
                    {% endfor %}
                ],
            {% endfor %}
        };


        const changeCategory = (select) => {
            // 子カテゴリの選択欄を空にする。
            categoryElement.children().remove();

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

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

            // 指定があれば、そのカテゴリを選択する
            if (select !== undefined) {
                categoryElement.val(select);
            }
        };


        $('#id_parent_category').on('change', () => {
            changeCategory();
        });


        // 入力値に問題があって再表示された場合、ページ表示時点で小カテゴリが絞り込まれるようにする
        if (parentCategoryElement.val()) {
            const selectedCategory = categoryElement.val();
            changeCategory(selectedCategory);
        }

    </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を取得し、紐づいている子カテゴリを選択欄に追加していく、という流れです。

        const changeCategory = (select) => {
            // 子カテゴリの選択欄を空にする。
            categoryElement.children().remove();

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

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

            // 指定があれば、そのカテゴリを選択する
            if (select !== undefined) {
                categoryElement.val(select);
            }
        };


        $('#id_parent_category').on('change', () => {
            changeCategory();
        });

1点注意なのは、入力内容に不備があって、入力画面がもう1回表示された場合です。この際、ページを表示した段階で、小カテゴリが既に絞り込まれているのが理想でしょう。

それをするために、次のようなコードを書いておきます。

        // 入力値に問題があって再表示された場合、ページ表示時点で小カテゴリが絞り込まれるようにする
        if (parentCategoryElement.val()) {
            const selectedCategory = categoryElement.val();
            changeCategory(selectedCategory);
        }

やっていることは、ぺージ表示時の段階で大カテゴリに何か値があれば、その値を基に小カテゴリを絞り込むという処理です。これは検索フォームのような例でもよく使うので、覚えておきましょう。

Ajaxを使う方法

Ajaxを使う方法でも実装してみます。この方法は、カテゴリ等が多すぎて予め定義しておくのが問題となりそうな場合に有効です(そんな状況はそうそう無いとは思いますが...)。

jQueryのajaxメソッド

まずはjQueryのajaxメソッドでやってみましょう。

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

{% block extrajs %}
    <script>
        const parentCategoryElement = $('#id_parent_category');
        const categoryElement = $('#id_category');

        const changeCategory = (select) => {
            // 子カテゴリの選択欄を空にする。
            categoryElement.children().remove();

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

                // 指定があれば、そのカテゴリを選択する
                if (select !== undefined) {
                    categoryElement.val(select);
                }

            });
        };

        parentCategoryElement.on('change', () => {
            changeCategory();
        });

        // 入力値に問題があって再表示された場合、ページ表示時点で小カテゴリが絞り込まれるようにする
        if (parentCategoryElement.val()) {
            const selectedCategory = categoryElement.val();
            changeCategory(selectedCategory);
        }
    </script>
{% endblock %}

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

from django.http import JsonResponse
from django.views import generic
from .forms import PostCreateForm
from .models import Post, Category


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


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で返す。
    return JsonResponse({'categoryList': category_list})

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

from django.urls import path
from . import views

app_name = 'app'

urlpatterns = [
    path('', views.PostCreate.as_view(), name='post_create'),
    path('api/category/get/', 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');

        const changeCategory = (select) => {
            // 子カテゴリの選択欄を空にする。
            categoryElement.innerHTML = '';

            const pk = parentCategoryElement[parentCategoryElement.selectedIndex].value;
            const url = `{% url 'app:ajax_get_category' %}?pk=${pk}`;

            fetch(url)
                .then(response => {
                    return response.json();
                })
                .then(response => {
                    for (const category of response.categoryList) {
                        const option = document.createElement('option');
                        option.value = category.pk;
                        option.innerHTML = category.name;
                        categoryElement.add(option);
                    }

                    if (select !== undefined) {
                        for (let i = 0, len = categoryElement.length; i < len; i++) {
                            const option = categoryElement[i];
                            if (option.value === select) {
                                option.selected = true;
                            }
                        }
                    }

                })
                .catch(error => {
                    console.log(error)
                });
        };

        parentCategoryElement.addEventListener('change', () => {
            changeCategory();
        });

        // 入力値に問題があって再表示された場合、ページ表示時点で小カテゴリが絞り込まれるようにする
        if (parentCategoryElement[parentCategoryElement.selectedIndex].value) {
            const selectedCategory = categoryElement[categoryElement.selectedIndex].value;
            changeCategory(selectedCategory);
        }
    </script>
{% endblock %}

この記事の関連記事

コメント欄

記事にコメントする

マーシー

いつもお世話になってます

コメントに返信する