Djangoで記事投稿時、選択した親カテゴリに応じた子カテゴリを選択できるようにする【Ajax】

Djangoアプリの新規投稿ページで、選択した親カテゴリに応じた子カテゴリを選択できるようにしてみましょう。ブログや掲示板で投稿のカテゴリを親子構造したい場合かつ子カテゴリが膨大な場合、親カテゴリを選択することで所属する子カテゴリのみ絞り込んで表示できるため便利です。

※この記事では弊サイトの Django入門編 を終えている前提で進めます。また、機能の実装先もDjango入門編で制作した掲示板アプリをベースに進めていきます。

目次

完成イメージ

新規投稿時に親カテゴリを選択すると、子カテゴリの選択肢をその親カテゴリに所属するもののみに絞り込むようにします。

次のように親カテゴリを選択していない状態では子カテゴリをすべて表示し、

親カテゴリを選択していない状態では子カテゴリをすべて表示

親カテゴリを選択すると、その親カテゴリに属する子カテゴリのみに絞り込みます。

親カテゴリを選択すると、その親カテゴリに属する子カテゴリのみに絞り込む

テーブル設計

親子カテゴリのテーブル構造

掲示板アプリの投稿用のArticlesテーブルにcategoryカラムを用意し、カテゴリー用のCategoryテーブルを作成して紐づけます。

Categoryテーブルはカテゴリー名を持つほか、外部キーとして所属する親カテゴリのIDを紐づけます。

モデル

from django.db import models
from django.urls import reverse

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 Article(models.Model):
    author = models.ForeignKey('auth.User', on_delete=models.CASCADE)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    category = models.ForeignKey(Category, verbose_name='カテゴリ', on_delete=models.PROTECT, null=True)

    def __str__(self):
        return self.content

    def get_absolute_url(self):
        return reverse('bbs:detail', kwargs={'pk': self.pk})

上記のテーブル設計をモデルに落とし込みます。

モデルの作成が完了したらマイグレーションでデータベースに反映します。

python manage.py makemigrations bbs
python manage.py migrate

弊サイトの Django入門編 などで既にArticleテーブルにレコードを登録している場合、次のようなエラーが発生します。

It is impossible to add a non-nullable field 'category' to article without specifying a default. This is because the database needs something to populate existing rows.
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit and manually define a default value in models.py.
Select an option:

これはArticleテーブルに新たに追加しようとしているcategoryフィールドがnullを許容していないため、既に存在するレコードのcategoryカラムをどうするか決めて下さい、というメッセージです。

練習環境なのでnullを許容してしまうのが楽です。

class Article(models.Model):
.
.
.
    category = models.ForeignKey(Category, verbose_name='カテゴリ', on_delete=models.PROTECT, null=True)
.
.
.

ルーティング

今回は予め新規投稿ページを用意してあるのでこのページのルーティングを新たに作る必要はありません。

次の create/ のルートをそのまま利用します。

from django.urls import path
from . import views

app_name = 'bbs'

urlpatterns = [
.
.
.
    path('create/', views.CreateView.as_view(), name='create'),
.
.
.
]

フォーム

次に記事(Article)を作成するためのモデルフォームを作成します。

前述のとおり今回Articleテーブルには親カテゴリのフィールドを持たせていないため、モデルフォームをそのまま出力した場合、親カテゴリの入力欄は表示されません。

Articleに親カテゴリのフィールドを持たせてしまってもいいのですが、フォームを作る際にフィールドとして追加してしまえばOKです。

Articleは子カテゴリの情報を持っているので、テンプレートで親カテゴリの情報を呼び出したい場合は article.category.parent でアクセスできます。

from django import forms
from .models import Article, ParentCategory


class ArticleCreateForm(forms.ModelForm):
    # 親カテゴリの選択欄を定義
    parent_category = forms.ModelChoiceField(
        label='親カテゴリ',
        queryset=ParentCategory.objects,
        required=False
    )

    class Meta:
        model = Article
        fields = ['content', 'parent_category', 'category']

fields = ‘__all__’ とするとすべてのフィールドを表示しますが、この掲示板アプリでは投稿者フィールドはリクエストユーザーの情報を自動的に格納するようにしているため、authorフィールドは不要です。

そこで表示したい項目のみのリストを作成して fields に定義しています。

ビュー

from django.urls import reverse_lazy
from django.views import generic

from .forms import ArticleCreateForm # 追加

from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied

.
.
.

class CreateView(LoginRequiredMixin, generic.edit.CreateView):
    model = Article
    form_class = ArticleCreateForm
    success_url = reverse_lazy('bbs:index')

    #格納する値をチェック
    def form_valid(self, form):
        form.instance.author = self.request.user
        return super(CreateView, self).form_valid(form)

.
.
.

forms.pyのArticleCreateFormクラスをインポート。

あとは使用するモデルとフォーム、投稿後のリダイレクト先を設定します。

authorフィールドにリクエストユーザーの情報を自動的に格納する処理はそのまま残しておきましょう。

テンプレート

定義したフォームを表示するためのテンプレートを作りましょう。

base.html

まずは共通テンプレート(base.html)です。

今回「選択した親カテゴリに応じた子カテゴリを選択できるようにする」処理のためにjQueryのajaxメソッドを使用します。

どうせなのでBootstrap4を使用しましょう。(趣旨と外れるのでこの記事ではデザインというか必要以上のクラスの適用はやりません)

<!DOCTYPE html>
<html lang="ja">
  <head>
    <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">

    <style>
      .container {
        background-color: #efefef;
      }
    </style>
    <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ではjQueryはスリム版を読み込んでいますが、スリム版ではajaxメソッドを使えないためフル版を読み込んでいます。

他のテンプレートで処理を行うためのJavaScriptを記述し、この block extrajs の部分にはめ込みます。

article_form.html

記事(Article)の追加画面で使用する article_form.html を作成します。

{% extends 'base.html' %}
{% block content %}

<h1>投稿の{{ object|yesno:'編集,新規作成'}}</h1><!-- 新規と編集で表示を変更 -->

<form action="" method="POST">
    {{ form.as_p }}
    {% csrf_token %}
    <button type='submit'>{{ object|yesno:'更新,作成'}}</button><!-- 新規と編集で表示を変更 -->
</form>

<div>
  <button onclick='JavaScript:history.back()'>戻る</button>
</div>

{% endblock %}

{% block extrajs %}
<script>
</script>
{% endblock %}

ビューから渡されたフォームを form.as_p で自動的に表示します。

表示自体はこれだけで、あとは block extrajs の中のスクリプトタグの中にjsコードを記述していきます。

Ajaxを使って処理を作る

article_form.html

article_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 'bbs: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リクエストを送信するので、それを受け付けるビューが必要です。

この bbs:ajax_get_category を作成しましょう。

views.py

from django.urls import reverse_lazy
from django.views import generic

from django.http import JsonResponse # 追加
from .models import Article, Category # 追加
from .forms import ArticleCreateForm # 追加

from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied

.
.
.

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

最終的にJSONで返すのでJsonResponseをインポート。

model.pyからArticleとCategory、forms.pyからArticleCreateFormをインポートしておきます。

処理の内容は下記サイト様の記事をそのまま使わせて頂きました。

https://blog.narito.ninja/detail/50

前提にしたモデルとアプリが異なるのでクラス名やurlの指定など少々変えております。見比べる際は注意。

urls.py

Ajaxを使ってHTTPリクエストが送られた際のルーティングも設定しておく必要があります。

from django.urls import path
from . import views

app_name = 'bbs'

urlpatterns = [
.
.
.
    path('create/', views.CreateView.as_view(), name='create'),
    path('api/category/get/', views.ajax_get_category, name='ajax_get_category'),
.
.
.
]

動作確認

新規投稿作成ページで親カテゴリと子カテゴリが選択できるようになっています

新規投稿作成ページで親カテゴリと子カテゴリが選択できるようになっています。

ここで親カテゴリを選択すると

親カテゴリを選択すると、その親カテゴリに属する子カテゴリのみに絞り込む

子カテゴリの選択肢が、その親カテゴリに属するもののみに絞り込まれます。

このエントリーをはてなブックマークに追加

コメントを残す

頂いたコメントは一読した後表示させて頂いております。
反映まで数日かかる場合もございますがご了承下さい。