DjangoのListViewで、ページをフィルタしてみた

DjangoのListViewを使って、こんな感じでページをフィルタしてみた時のメモです。

f:id:thinkAmi:20160316232959p:plain

 
ただ、以下の実装で本当に良いのか分かりませんので、何かあればご指摘ください。

 

環境

  • Windows10
  • Python 3.5.1
  • Django 1.9.4
    • myprojectプロジェクトに、myappアプリを追加

 

Modelの用意

以下の2つのModelを用意します。

  • Kind
  • Food
    • 一覧表示用
    • Kindを外部キーとして持つ
    • Food.nameでもフィルタを行う
myapp/models.py
from django.db import models

class Kind(models.Model):
    name = models.CharField('Name', max_length=255)
    
    
class Food(models.Model):
    kind = models.ForeignKey(Kind, verbose_name="品種")
    name = models.CharField('名前', max_length=255)

 

Viewの用意

ListView.modelにはモデルFoodをセットします。

また、チェックボックス用のモデルKindは、テンプレートで使えるようにget_context_data()を使ってcontextへとセットします。
Multiple object mixins - get_context_data() | Django documentation | Django

myapp/views.py
class FoodListView(ListView):
    model = Food

    def get_context_data(self, **kwargs):
        context = super(FoodListView, self).get_context_data(**kwargs)

        kinds = Kind.objects.all()
        context['kinds'] = kinds
        
        return context

 

テンプレートで、フィルタ用のフォームを作成

Viewから受け取ったkindsを使ってチェックボックスを作ります。

myapp/templates/myapp/food_list.html
<form method="get" action="" name="filter_form">
    <legend>絞り込み条件</legend>
    <div>
        <span>種類:</span>
        {% for kind in kinds %}
            <input type="checkbox" id="filter_kind_{{ kind.pk }}" 
                name="kind" 
                value="{{ kind.pk }}">{{ kind.name }}
        {% endfor %}
    </div>
    <div>
        <span>品種:</span>
        <input type="search" id="filter_name"
            name="name" 
            placeholder="品種名">
        <button id="filter">絞り込み</button>
    </div>
</form>

 

Viewで、Modelのフィルタを作成

上記のフォームを使ってGETすると、Viewにクエリパラメータが渡されます。

そこで、クエリパラメータを元に、ListViewのget_queryset()でModelのフィルタを行ったQuerySetを作成し、テンプレートへと返すように実装します。

クエリパラメータは request.GETというQueryDictから以下の方法で取得します。

myapp/view.py
def get_queryset(self):
    # デフォルトは全件取得
    results = self.model.objects.all()

    # GETのURLクエリパラメータを取得する
    # 該当のクエリパラメータが存在しない場合は、[]が返ってくる
    q_kinds = self.request.GET.getlist('kind')
    q_name = self.request.GET.get('name')

    # 品種での絞込は、Kind.pkとして存在してる値のみ対象とする
    # "a"とかを指定するとValueErrorになるため
    if len(q_kinds) != 0:
        kinds = [x for x in q_kinds if x in ["1", "2"]]
        results = results.filter(kind__in=kinds)
    
    # 名前での絞り込み
    if q_name is not None:
        results = results.filter(name__contains=q_name)
        
    return results

 

テンプレートで、フィルタしたデータを表示

Viewで作成したQuerySetは、テンプレートではobject_list(デフォルト名)にて参照できます。
Multiple object mixins - get_context_object_name | Django documentation | Django

また、food.kind.nameのような形で外部キーのフィールド(今回は種類名)を表示できます。
python - Django foreign key relation in template - Stack Overflow

myapp/templates/myapp/food_list.html
<div id="main">
    <h1>結果</h1>
    <table>
        <tr>
            <th>種類</th>
            <th>品種</th>
        </tr>
        {% for food in object_list %}
            <tr>
                <td>{{ food.kind.name }}</td>
                <td>{{ food.name }}</td>
            </tr>
        {% endfor %}
    </table>
</div>

 

クエリパラメータとフォーム状態とを連動

ここまででフィルタはできるものの、絞り込みボタンを押すとチェックボックスやテキストボックスの値がクリアされてしまいます。

これでは使い勝手が悪いので、クエリパラメータとフォーム状態とを連動させます。

チェックボックスとの連動

クエリパラメータのkindとHTMLタグのcheckedを連動させる必要があります。そのため、クエリパラメータの値をcheckedという文字列へと何らかの形で変換します。

テンプレートではPythonDjangoの関数を直接呼ぶことができないことから、

  • カスタムテンプレートフィルタ
  • カスタムテンプレートタグ

を使い、Djangoの関数で変換できるようにします。
Custom template tags and filters | Django documentation | Django

 
今回はカスタムテンプレートフィルタを使ってみます。カスタムテンプレートフィルタの実装では、

  • 第一引数で、自身の値
  • 第二引数で、任意の値

を受け取ることができます。

そのため、テンプレートからkind.pkとQueryDictを渡してcheckedを表示するかどうかを決めます。

myapp/templatetags/custom_filters.py
from django import template

register = template.Library()

@register.filter
def checked(value, querydict):
    kinds = querydict.getlist('kind')
    if str(value) in kinds:
        return "checked"
    return ""

 

テキストボックスとの連動

こちらも同じようにして、カスタムテンプレートフィルタを使います。

myapp/templatetags/custom_filters.py
@register.filter
def name(querydict):
    name = querydict.get('name')
    
    return "" if name is None else name

 

テンプレートへ、カスタムテンプレートフィルタまわりを追加

テンプレートでは

  • {% load custom_filters %}を追加
  • チェックボックスとテキストボックスのタグに、カスタムテンプレートフィルタを追加
    • kind.pk|checked:request.GET
    • request.GET|name

を追加します。

なお、チェックボックスのカスタムテンプレートフィルタでQueryDictを渡すために、:でカスタムテンプレートフィルタと引数を連結しています。

myapp/templates/myapp/food_list.html
{% load custom_filters %}

...
<form method="get" action="" name="filter_form">
    <legend>絞り込み条件</legend>
    <div>
        <span>種類:</span>
        {% for kind in kinds %}
            <input type="checkbox" id="filter_kind_{{ kind.pk }}" 
                name="kind" 
                value="{{ kind.pk }}"
                {{ kind.pk|checked:request.GET }}    <!-- 追加 -->
                >{{ kind.name }}
        {% endfor %}
    </div>
    <div>
        <span>品種:</span>
        <input type="search" id="filter_name"
            name="name" 
            placeholder="品種名"
            value={{ request.GET|name }}>    <!-- 追加 -->
        <button id="filter">絞り込み</button>
    </div>
</form>

 

ここまでの動作

全件表示

f:id:thinkAmi:20160317001044p:plain

 

チェックボックスでフィルタ

f:id:thinkAmi:20160317001102p:plain

 

テキストボックスでフィルタ

f:id:thinkAmi:20160317001117p:plain

 

両方でフィルタ

f:id:thinkAmi:20160317001130p:plain

 

フィルタを考慮したページングの追加

ここまでで、フィルタ・クエリパラメータとフォーム状態の連動ができました。

ただ、フィルタしても件数が多い場合が考えられるので、ページングも追加してみます。

今回は、過去記事のページ番号をすべて表示するタイプを参考に、

  • Viewにpaginate_byを追加
  • テンプレートにページング部分を追加

とします。
Djangoで、Paginatorやdjango-pure-paginationを使ってページングしてみた - メモ的な思考的な

 
ただ、これだけではフィルタを考慮したページングとはならないため、ページ移動した場合にクエリパラメータが失われてしまいます。

その対応方法としては、

などがありましたが、今回はstackoverflowの回答を元に、カスタムテンプレートタグを作ります。

 

カスタムテンプレートタグの追加

カスタムテンプレートフィルタと同様、templatetagsディレクトリの中に入れます。

今回は、カスタムテンプレートフィルタとは別ファイル(custom_tags.py)として作成します。

内容は、

とします。

myapp/templatetags/custom_tags.py`
from django import template

register = template.Library()

@register.simple_tag
def query_string(request, page_number):
    querydict = request.GET.copy()
    querydict['page'] = page_number
    
    return querydict.urlencode()

 

テンプレートへ、ページングを追加

ページング部分は以前の記事とほぼ同様ですが、

  • カスタムテンプレートタグを呼ぶための {% load custom_tags %}
  • ページング部分は <a href="{% url 'my:list' %}?{% query_string request page_obj.previous_page_number %}">previous</a> とする
    • urls.pyにて、localhost:8080/mysite/をmyappアプリのURLとしたため

となります。

{% if is_paginated %}
    <ul class="pagination">
        {% if page_obj.has_previous %}
            <li><a href="{% url 'my:list' %}?{% query_string request page_obj.previous_page_number %}">previous</a></li>
        {% endif %}
        {% for link_page in page_obj.paginator.page_range %}
            {% if link_page == page_obj.number %}
                <li class="active">{{ link_page }}</li>
            {% else %}
                <li><a href="{% url 'my:list' %}?{% query_string request link_page %}">{{ link_page }}</a></li>
            {% endif %}
        {% endfor %}

        {% if page_obj.has_next %}
            <li><a href="{% url 'my:list' %}?{% query_string request page_obj.next_page_number %}">next</a></li>
        {% endif %}
    </ul>
{% endif %}

 
以上で、フィルタ + ページング機能を持ったListViewができました。

f:id:thinkAmi:20160317001220p:plain

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/Django_ListView_filter_sample

冒頭にも書きましたが、本当にこれで良いのか分かりませんので、何かあればご指摘ください。

 

その他

品種名を入力しない場合、クエリパラメータが&name=となってしまいますが、今回はそのままにしておきました。

それに対応する場合は、以下のstackoverflowが参考になるかもしれません。
Don't include blank fields in GET request emitted by Django form - Stack Overflow