Djangoメモ(31) : クラスベース汎用ビューのListViewで一覧表示とページネーション
- ListViewで一覧ページ作成
- 対話型シェルでページネーションの確認
- 関数ベースビューでのページネーション
- クラスベース汎用ビューのListViewでのページネーション
- ページネーションのテンプレートを再利用
- まとめ
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
にはテンプレート名を指定する。
これで、これまでと同じ画面が表示されるようになる。
また、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)
これで以下のようにデータが追加される。
それではページネーションについて確認してみる。なお、ページネーションの詳細はドキュメントを参照。
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 %}
問題がなければ以下のページネーションが表示される。
クラスベース汎用ビューの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一覧にもページネーションが追加される。
まとめ
- クラスベース汎用ビューのListViewで一覧ページが作成可能
Paginator
クラスでページネーション- 独自の処理を追加したい場合は
get_context_data
,get_queryset
をオーバーライド - ページネーションのテンプレートは再利用できるようにしておくと便利