DjangoのListViewでPaginatorを使って絞り込み検索結果をページングする方法

Django

はじめに

この記事では、DjangoでListViewクラスを使ったページネーションを実装する方法を紹介します。
Djangoを使ってシステムを作成するときに、この機能を使うと利便性が向上しますのでお役に立てれば幸いです。

サンプルではPaginatorを使用しており、空ページだったときなどの挙動も制御することができるので大変便利です。
Paginatorを使っていないバージョンはこの記事にあります。
違いはviews.pyのみなので、気楽に試せると思います。

環境

- Django 3.2.4
- Python 3.9.5

ファイル構成

ファイル構成です。
テンプレートファイルをひとつのディレクトリにまとめています。
ページネーション部のファイルは別ファイルにしてbase.htmlと同じ階層に置いています。

proj
 ┣ -- proj
        ┣ -- settings.py
 
 ┣ -- app
        ┣ -- views.py

 ┣ -- templates
        ┣ -- base.html
        ┣ -- pagination.html
        ┣ -- app
              ┣ -- list.html
 ┣ -- static
        ┣ -- css
             ┣ -- bootstrap.min.css.map 

views.py

検索キーワードでモデルインスタンスの一覧を加工するためにget_querysetメソッドにページネーションを記述します。

AND検索

複数の単語をスペースで区切ってAND検索ができるようにしています。
キーワードを空欄にして検索した場合は、keyword変数が空になるため全てのデータが表示対象となります。

【Django】サイト内検索機能を組み込んで複数のキーワード入力に対応させる
チェック

テンプレートで使用するpaginate_by変数

paginate_byで1ページの表示件数を指定します。
この変数はpagination.htmlでis_paginatedとして使用します。
Paginator関数でも表示件数を指定する必要があるので、「sef.paginate_by」として指定します。

add_message

request変数をテンプレートに渡すためにメッセージフレームワークのadd_messageを使用します。
django.core.paginatorを使用するので、テンプレートに渡す変数は第1引数としてrequest、第3引数としてpage_objとなります。

ソース

from django.views.generic import ListView

# 絞り込み検索
from django.db.models import Q
from functools import reduce
from operator import and_

# Pagination
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.contrib import messages

# 定義したモデルの読み込み
from test.models import Test

class TestlListView(ListView):
    template_name = 'app/list.html'
    model         = Test
    # 1ページに5件を表示する
    paginate_by   = 5

    def get_queryset(self):
        queryset = Test.objects.order_by('xxx', 'ooo')
        keyword  = self.request.GET.get('keyword')
        # AND検索
        if keyword:
            # キーワードを区切っている全角スペースを半角スペースに変換
            exclusion = set([' ', ' '])
            q_list = ''
            for i in keyword:
                if i in exclusion:
                    pass
                else:
                    q_list += i
            query = reduce(
                and_, [Q(name__icontains=q) | Q(yomigana__icontains=q) for q in q_list]
            )
            queryset = queryset.filter(query)

        # Paginator
        paginator = Paginator(queryset, self.paginate_by)
        page      = self.request.GET.get('page')
        try:
            page_obj = paginator.page(page)
        except PageNotAnInteger:
            page_obj = paginator.page(1)
        except EmptyPage:
            page_obj = paginator.page(paginator.num_pages)

        # テンプレートに変数を渡す
        messages.add_message(self.request, messages.INFO, page_obj)
        return queryset

pagination.html

他のページでも使用するため、ページネーション部は別ファイルとします。

keyword変数

検索結果を引き継いでページネーションを行うためrequest.GET.keywordの有無で場合分けします。
request.GET.keywordのkeywordはlist.htmlで指定したinput項目のname変数を指します。
「&keyword=」のkeywordも同様です。
keyword以外の名前に変更する場合は修正してください。
各ページ番号の表示部はページ数が多くなると横長になって邪魔なのでコメントしています。

ソース

{% if is_paginated %}
    <nav aria-label="Page navigation">
    <ul class="pagination justify-content-center">
    {% if page_obj.has_previous %}
        <li class="page-item">
        <a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.keyword %}&keyword={{ request.GET.keyword }}{% endif %}" aria-label="Previous">
            <span aria-hidden="true">&laquo;</span>
            <span class="sr-only">前へ</span>
        </a>
        </li>

        <!-- 「前へ」と「次へ」の間の装飾文字 -->
        <li class="page-item">
        <span class="sr-only">..........</span>
        </li>
    {% endif %}

<!--
    {% for num in paginator.page_range %}
        {% if page_obj.number == num %}
            <li class="page-item active">
            <span class="page-link">
            {{ num }}
            <span class="sr-only">(current)</span>
            </span>
            </li>
        {% else %}
            <li class="page-item">
            <a class="page-link" href="?page={{ num }}{% if request.GET.keyword %}&keyword={{ request.GET.keyword }}{% endif %}">{{ num }}</a>
            </li>
        {% endif %}
    {% endfor %}
-->

    {% if page_obj.has_next %}
        <li class="page-item">
        <a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.keyword %}&keyword={{ request.GET.keyword }}{% endif %}" pagearia-label="Next">
        <span aria-hidden="true">&raquo;</span>
        <span class="sr-only">次へ</span>
        </a>
        </li>
    {% endif %}

    <li class="p-md-2">
    {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
    </li>
    </ul>
    </nav>
{% endif %}

list.html

読み込むテンプレートファイルです。
ページネーションを記述したファイルは別ファイルなのでincludeで読み込みます。
ファイルのパスはsettings.pyのTEMPLATES変数で指定した相対パスとなります。

{% extends 'base.html' %}
{% load i18n static %}

{% block list %}
<!-- 絞り込み検索 get -->
<form action="" method="get">
{% csrf_token %}
<p class='center'>
<input class="form-control" type="text" name="keyword">
<br>
<input type="submit" class="btn btn-info" value="絞り込み検索" name="search">
</p>
</form>

<form action="" method="post">
<table class="table table-bordered table-striped">
<tr>
<th class="alert-warning">項目1</th>
</tr>
{% for f in xxx_item %}
    <tr>
    <td>{{ f.xxx }}</td>
    </tr>
{% endfor %}
</table>
</form>

<!-- ページネーションのファイルを読み込む -->
{% include 'pagination.html' %}

{% endblock list %}

base.html

list.htmlで読み込むbase.htmlファイルです。
HTMLのヘッダやフッタ、必要なCSSファイルなどを設定します。

{% load i18n static %}
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" type="image/png" href="{% static 'favicon/favicon.ico' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
<title>テスト</title>
</head>
<body>

<!-- container: 表示幅を可変にする。-->
<div class="container-fluid">

{% block list %}
{% endblock %}

<!-- フッタ開始 -->
<footer class="container-fluid">
<p class="small">
フッタのテスト
<br>
Copyright&copy; テスト
</p>
</footer>
<!-- フッタ終了 -->

</div>
<!-- end container -->

</body>
</html>

settings.py(抜粋)

テンプレートファイルをひとつのディレクトリで管理したいため、settings.pyでTEMPLATES変数を以下のように指定します。
同様に下のようにSTATICファイルもpathlibを使って指定しています。

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [ BASE_DIR / 'templates'],  # Templateを一箇所にまとめる設定
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

# Static files (CSS, JavaScript, Images)
STATIC_URL = '/static/'
STATICFILES_DIRS = [
    BASE_DIR / 'static/'
]

まとめ

目的は達していますが、ページ番号の表示部分は改良の余地があると思いますので、適宜修正する予定です。

Comments