もた日記

くだらないことを真面目にやる

Djangoメモ(31) : クラスベース汎用ビューのListViewで一覧表示とページネーション

Python 3.6.4 Django 2.0.2

A Complete Beginner's Guide to Djangoのチュートリアルを参考にクラスベース汎用ビューのListViewを使用してみる。


ListViewで一覧ページ作成

前回はUpdateViewを使用して編集機能を作成したが、今回はListViewを使用して一覧ページを作成する。
これまでは関数ベースビューを使用して以下のように書いていた。

myproject/urls.py

path('', views.home, name='home'),

boards/views.py

from django.shortcuts import render
from .models import Board

def home(request):
    boards = Board.objects.all()
    return render(request, 'home.html', {'boards': boards})

上記をListViewを使用した方法で書き換えると以下のようになる。

myproject/urls.py

path('', views.BoardListView.as_view(), name='home'),

boards/views.py

from django.views.generic import ListView
from .models import Board

class BoardListView(ListView):
    model = Board
    context_object_name = 'boards'
    template_name = 'home.html'

modelにはモデル名、context_object_nameにはテンプレートで参照する際の名前、template_nameにはテンプレート名を指定する。
これで、これまでと同じ画面が表示されるようになる。

f:id:wonder-wall:20180328202814p:plain

また、boards/tests/test_view_home.pyテストは以下のように変更する(変更前はself.assertEquals(view.func, home))。

from django.test import TestCase
from django.urls import resolve
from ..views import BoardListView

class HomeTests(TestCase):
    # ...
    def test_home_url_resolves_home_view(self):
        view = resolve('/')
        self.assertEquals(view.func.view_class, BoardListView)


対話型シェルでページネーションの確認

リスト表示では数が多い場合にページネーションを使用するが、ListViewではページネーションを簡単に実装できる。
実装を進める前にまずは対話型シェルでページネーションの基本を確認してみる。
ページネーションの確認にはデータ数が必要なので最初にpython manage.py shellで対話型シェルを起動してデータを追加する。

from django.contrib.auth.models import User
from boards.models import Board, Topic, Post

user = User.objects.first()
board = Board.objects.get(name='Django')

for i in range(100):
    subject = 'Topic test #{}'.format(i)
    topic = Topic.objects.create(subject=subject, board=board, starter=user)
    Post.objects.create(message='Message test...', topic=topic, created_by=user)

これで以下のようにデータが追加される。

f:id:wonder-wall:20180328203519p:plain

それではページネーションについて確認してみる。なお、ページネーションの詳細はドキュメントを参照。

In [1]: from boards.models import Topic

# 全Topicの数。既に登録していたデータもあるので100以上ある。
In [2]: Topic.objects.count()
Out[2]: 106

# 'Django' Boardに属するTopicの数。
In [3]: Topic.objects.filter(board__name='Django').count()
Out[3]: 103

# QuerySetを取得する。
In [4]: queryset = Topic.objects.filter(board__name='Django').order_by('-last_updated')

In [5]: from django.core.paginator import Paginator

# Paginatorクラス。QuerySetと1ページに表示するアイテム数を渡す。
In [6]: paginator = Paginator(queryset, 20)

# QuerySetのcount()と同じ
In [7]: paginator.count
Out[7]: 103

# ページ数。103÷20で6ページ。最後のページのアイテム数は3つ。
In [8]: paginator.num_pages
Out[8]: 6

# ページのレンジ。
In [9]: paginator.page_range
Out[9]: range(1, 7)

# ページのインスタンスを返す。
In [10]: paginator.page(2)
Out[10]: <Page 2 of 6>

In [11]: page = paginator.page(2)

In [12]: type(page)
Out[12]: django.core.paginator.Page

In [13]: type(paginator)
Out[13]: django.core.paginator.Paginator

# 存在しないページを指定すると例外が発生。
In [14]: paginator.page(7)
EmptyPage: That page contains no results

# 数値以外も例外が発生。
In [15]: paginator.page('abc')
PageNotAnInteger: That page number is not an integer

In [16]: page = paginator.page(1)

# 次のページがあるか。
In [17]: page.has_next()
Out[17]: True

# 前のページがあるか。
In [18]: page.has_previous()
Out[18]: False

# 次のページまたは前のページがあるか。
In [19]: page.has_other_pages()
Out[19]: True

# 次のページの番号
In [20]: page.next_page_number()
Out[20]: 2

# 前のページの番号。存在しない場合は例外が発生。
In [21]: page.previous_page_number()
EmptyPage: That page number is less than 1


関数ベースビューでのページネーション

ページネーションの使い方はなんとなくわかったので関数ベースビューでTopic一覧ページを実装してみる。
boards/views.pyを以下のように編集。

from django.db.models import Count
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.shortcuts import get_object_or_404, render
from django.views.generic import ListView
from .models import Board

def board_topics(request, pk):
    board = get_object_or_404(Board, pk=pk)
    queryset = board.topics.order_by('-last_updated').annotate(replies=Count('posts') - 1)
    page = request.GET.get('page', 1)

    paginator = Paginator(queryset, 20)

    try:
        topics = paginator.page(page)
    except PageNotAnInteger:
        # fallback to the first page
        topics = paginator.page(1)
    except EmptyPage:
        # probably the user tried to add a page number
        # in the url, so we fallback to the last page
        topics = paginator.page(paginator.num_pages)

    return render(request, 'topics.html', {'board': board, 'topics': topics})

ここでtopicsはQuerySetではなく、ページのインスタンスになっていることに注意。
テンプレートではBootstrap 4のPaginationコンポーネントを用いて以下のようにページネーションを実現する。

templates/topics.html(クリックで展開)

{% extends 'base.html' %}

{% block title %}
  {{ board.name }} - {{ block.super }}
{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item active">{{ board.name }}</li>
{% endblock %}

{% block content %}
  <div class="mb-4">
    <a href="{% url 'new_topic' board.pk %}" class="btn btn-primary">New topic</a>
  </div>

  <table class="table mb-4">
    <thead class="thead-dark">
      <tr>
        <th>Topic</th>
        <th>Starter</th>
        <th>Replies</th>
        <th>Views</th>
        <th>Last Update</th>
      </tr>
    </thead>
    <tbody>
      {% for topic in topics %}
        <tr>
          <td><a href="{% url 'topic_posts' board.pk topic.pk %}">{{ topic.subject }}</a></td>
          <td>{{ topic.starter.username }}</td>
          <td>{{ topic.replies }}</td>
          <td>{{ topic.views }}</td>
          <td>{{ topic.last_updated }}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>

  {% if topics.has_other_pages %}
    <nav aria-label="Topics pagination" class="mb-4">
      <ul class="pagination">
        {% if topics.has_previous %}
          <li class="page-item">
            <a class="page-link" href="?page={{ topics.previous_page_number }}">Previous</a>
          </li>
        {% else %}
          <li class="page-item disabled">
            <span class="page-link">Previous</span>
          </li>
        {% endif %}

        {% for page_num in topics.paginator.page_range %}
          {% if topics.number == page_num %}
            <li class="page-item active">
              <span class="page-link">
                {{ page_num }}
                <span class="sr-only">(current)</span>
              </span>
            </li>
          {% else %}
            <li class="page-item">
              <a class="page-link" href="?page={{ page_num }}">{{ page_num }}</a>
            </li>
          {% endif %}
        {% endfor %}

        {% if topics.has_next %}
          <li class="page-item">
            <a class="page-link" href="?page={{ topics.next_page_number }}">Next</a>
          </li>
        {% else %}
          <li class="page-item disabled">
            <span class="page-link">Next</span>
          </li>
        {% endif %}
      </ul>
    </nav>
  {% endif %}

{% endblock %}

問題がなければ以下のページネーションが表示される。

f:id:wonder-wall:20180329204928p:plain


クラスベース汎用ビューのListViewでのページネーション

今度はListViewを使ってTopic一覧ページのページネーションを実装してみる。
boards/views.pyを以下のように書き換える。

class TopicListView(ListView):
    model = Topic
    context_object_name = 'topics'
    template_name = 'topics.html'
    paginate_by = 20

    def get_context_data(self, **kwargs):
        kwargs['board'] = self.board
        return super().get_context_data(**kwargs)

    def get_queryset(self):
        self.board = get_object_or_404(Board, pk=self.kwargs.get('pk'))
        queryset = self.board.topics.order_by('-last_updated').annotate(replies=Count('posts') - 1)
        return queryset

ListViewを使うとテンプレート側ではpaginator, page_obj, is_paginated, object_listという変数が使える。object_listについてはcontext_object_nameで別名を付けることもできる。
paginate_by = 20で1ページに表示するアイテム数を指定し、get_context_data()ではコンテキストデータにboardを追加するためにオーバーライドしている。同様にget_queryset()もオーバーライドしている(オーバーライドしないとall()になる)。

次にmyproject/urls.pyをクラスベースビューの書き方に変更。

from django.conf.urls import url
from boards import views

urlpatterns = [
    # ...
    # path('boards/<int:pk>/', views.board_topics, name='board_topics'),
    path('boards/<int:pk>/', views.TopicListView.as_view(), name='board_topics'),
]

テンプレートは以下の通り。

templates/topics.html(クリックで展開)

{% extends 'base.html' %}

{% block title %}
  {{ board.name }} - {{ block.super }}
{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item active">{{ board.name }}</li>
{% endblock %}

{% block content %}
  <div class="mb-4">
    <a href="{% url 'new_topic' board.pk %}" class="btn btn-primary">New topic</a>
  </div>

  <table class="table mb-4">
    <thead class="thead-dark">
      <tr>
        <th>Topic</th>
        <th>Starter</th>
        <th>Replies</th>
        <th>Views</th>
        <th>Last Update</th>
      </tr>
    </thead>
    <tbody>
      {% for topic in topics %}
        <tr>
          <td><a href="{% url 'topic_posts' board.pk topic.pk %}">{{ topic.subject }}</a></td>
          <td>{{ topic.starter.username }}</td>
          <td>{{ topic.replies }}</td>
          <td>{{ topic.views }}</td>
          <td>{{ topic.last_updated }}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>

  {% if is_paginated %}
    <nav aria-label="Topics pagination" class="mb-4">
      <ul class="pagination">
        {% if page_obj.has_previous %}
          <li class="page-item">
            <a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a>
          </li>
        {% else %}
          <li class="page-item disabled">
            <span class="page-link">Previous</span>
          </li>
        {% endif %}

        {% for page_num in paginator.page_range %}
          {% if page_obj.number == page_num %}
            <li class="page-item active">
              <span class="page-link">
                {{ page_num }}
                <span class="sr-only">(current)</span>
              </span>
            </li>
          {% else %}
            <li class="page-item">
              <a class="page-link" href="?page={{ page_num }}">{{ page_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 }}">Next</a>
          </li>
        {% else %}
          <li class="page-item disabled">
            <span class="page-link">Next</span>
          </li>
        {% endif %}
      </ul>
    </nav>
  {% endif %}

{% endblock %}

boards/tests/test_view_board_topics.pyテストも書き換えておく。

from django.test import TestCase
from django.urls import resolve
from ..views import TopicListView

class BoardTopicsTests(TestCase):
    # ...
    def test_board_topics_url_resolves_board_topics_view(self):
        view = resolve('/boards/1/')
        self.assertEquals(view.func.view_class, TopicListView)

上記のように変更するとListViewによりページネーションが実現できる(結果は関数ベースビューの場合と同じ)。


ページネーションのテンプレートを再利用

ページネーションを毎回記述するのは面倒なのて再利用できるようにしておく。
Post一覧ページをページネーションできるようにboards/views.pyを編集。ページネーションが確認しやすいようにpaginate_by = 2とする。

class PostListView(ListView):
    model = Post
    context_object_name = 'posts'
    template_name = 'topic_posts.html'
    paginate_by = 2

    def get_context_data(self, **kwargs):
        self.topic.views += 1
        self.topic.save()
        kwargs['topic'] = self.topic
        return super().get_context_data(**kwargs)

    def get_queryset(self):
        self.topic = get_object_or_404(Topic, board__pk=self.kwargs.get('pk'), pk=self.kwargs.get('topic_pk'))
        queryset = self.topic.posts.order_by('created_at')
        return queryset

myproject/urls.pyも編集。

from django.conf.urls import url
from boards import views

urlpatterns = [
    # ...
    #path('boards/<int:pk>/topics/<int:topic_pk>/', views.topic_posts, name='topic_posts'),
    path('boards/<int:pk>/topics/<int:topic_pk>/', views.PostListView.as_view(), name='topic_posts'),
]

再利用可能なページネーションのテンプレートをtemplates/includesディレクトリにpagination.htmlとして作成する。

└── templates/
   ├── includes/
   │  ├── form.html
   │  └── pagination.html

templates/includes/pagination.html

{% if is_paginated %}
  <nav aria-label="Topics pagination" class="mb-4">
    <ul class="pagination">
      {% if page_obj.has_previous %}
        <li class="page-item">
          <a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a>
        </li>
      {% else %}
        <li class="page-item disabled">
          <span class="page-link">Previous</span>
        </li>
      {% endif %}

      {% for page_num in paginator.page_range %}
        {% if page_obj.number == page_num %}
          <li class="page-item active">
            <span class="page-link">
              {{ page_num }}
              <span class="sr-only">(current)</span>
            </span>
          </li>
        {% else %}
          <li class="page-item">
            <a class="page-link" href="?page={{ page_num }}">{{ page_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 }}">Next</a>
        </li>
      {% else %}
        <li class="page-item disabled">
          <span class="page-link">Next</span>
        </li>
      {% endif %}
    </ul>
  </nav>
{% endif %}

topic_posts.htmlではページネーションのテンプレートを{% include 'includes/pagination.html' %}でインクルード。

templates/topic_posts.html(クリックで展開)

{% extends 'base.html' %}

{% load static %}

{% block title %}{{ topic.subject }}{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' topic.board.pk %}">{{ topic.board.name }}</a></li>
  <li class="breadcrumb-item active">{{ topic.subject }}</li>
{% endblock %}

{% block content %}

  <div class="mb-4">
    <a href="{% url 'reply_topic' topic.board.pk topic.pk %}" class="btn btn-primary" role="button">Reply</a>
  </div>

  {% for post in posts %}
    <div class="card {% if forloop.last %}mb-4{% else %}mb-2{% endif %} {% if forloop.first %}border-dark{% endif %}">
      {% if forloop.first %}
        <div class="card-header text-white bg-dark py-2 px-3">{{ topic.subject }}</div>
      {% endif %}
      <div class="card-body p-3">
        <div class="row">
          <div class="col-2">
            <img src="{% static 'img/avatar.svg' %}" alt="{{ post.created_by.username }}" class="w-100">
            <small>Posts: {{ post.created_by.posts.count }}</small>
          </div>
          <div class="col-10">
            <div class="row mb-3">
              <div class="col-6">
                <strong class="text-muted">{{ post.created_by.username }}</strong>
              </div>
              <div class="col-6 text-right">
                <small class="text-muted">{{ post.created_at }}</small>
              </div>
            </div>
            {{ post.message }}
            {% if post.created_by == user %}
              <div class="mt-3">
                <a href="{% url 'edit_post' post.topic.board.pk post.topic.pk post.pk %}"
                   class="btn btn-primary btn-sm"
                   role="button">Edit</a>
              </div>
            {% endif %}
          </div>
        </div>
      </div>
    </div>
  {% endfor %}

  {% include 'includes/pagination.html' %}

{% endblock %}

topics.htmlも同様に書き換える。

templates/topics.html(クリックで展開)

{% extends 'base.html' %}

{% block title %}
  {{ board.name }} - {{ block.super }}
{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item active">{{ board.name }}</li>
{% endblock %}

{% block content %}
  <div class="mb-4">
    <a href="{% url 'new_topic' board.pk %}" class="btn btn-primary">New topic</a>
  </div>

  <table class="table mb-4">
    <thead class="thead-dark">
      <tr>
        <th>Topic</th>
        <th>Starter</th>
        <th>Replies</th>
        <th>Views</th>
        <th>Last Update</th>
      </tr>
    </thead>
    <tbody>
      {% for topic in topics %}
        <tr>
          <td><a href="{% url 'topic_posts' board.pk topic.pk %}">{{ topic.subject }}</a></td>
          <td>{{ topic.starter.username }}</td>
          <td>{{ topic.replies }}</td>
          <td>{{ topic.views }}</td>
          <td>{{ topic.last_updated }}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>

  {% include 'includes/pagination.html' %}

{% endblock %}

boards/tests/test_view_topic_posts.pyテストの書き換えも忘れないこと。

from django.test import TestCase
from django.urls import resolve
from ..views import PostListView

class TopicPostsTests(TestCase):
    # ...
    def test_view_function(self):
        view = resolve('/boards/1/topics/1/')
        self.assertEquals(view.func.view_class, PostListView)

これでPost一覧にもページネーションが追加される。

f:id:wonder-wall:20180329211418p:plain


まとめ

  • クラスベース汎用ビューのListViewで一覧ページが作成可能
  • Paginatorクラスでページネーション
  • 独自の処理を追加したい場合はget_context_data, get_querysetをオーバーライド
  • ページネーションのテンプレートは再利用できるようにしておくと便利