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

Twitterでシェア FaceBookでシェア はてなブックマークでシェア

Python - Django
2018年12月3日20:47に更新(約10日前)
2018年10月31日12:24に作成(約43日前)

旧ブログ移行記事です。

概要

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

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

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

非常によく使う機能です。

準備

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')

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を使えません(自分でXMLHttpRequestを書くなら不要)。なので、スリムじゃないほうを読み込みます。

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

他のテンプレートでJavaScriptを書きやすいように、{% block extrajs %}を定義します。Ajaxを今回使うので、jQueryの読み込みの後に定義するようにしましょう。

{% 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>
var 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').change(function() {
    // 子カテゴリの選択欄を空にする。
    var categoryElement = $('#id_category');
    categoryElement.children().remove();

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

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

</script>
{% endblock %}

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

var categories = {

    '3': [

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

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

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

    ],

    '4': [

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

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

    ],

};

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

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

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

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

2. Ajaxを使う

Ajaxを使う方法でも実装してみます。 post_form.htmlの{% block extrajs %}内を実装します。

{% block extrajs %}
<script>

  $('#id_parent_category').change(function() {
    // 子カテゴリの選択欄を空にする。
    var categoryElement = $('#id_category');
    categoryElement.children().remove();
    var pk = $(this).val();

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

</script>
{% endblock %}

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

def 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.get_category, name='get_category')

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

Twitterでシェア FaceBookでシェア はてなブックマークでシェア

記事にコメントする

マーシー
2018年11月29日19:30にコメント(約14日前)

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

コメントに返信する