(最終更新月:2023年1月)
✔当記事はこんな方に向けて書かれています
「アーカイブページが作りたい」
「ユーザー別のブログ・日記・日報ページを簡単に作る方法ってないのかな?」
「実例があるとわかりやすいんだけど、、、」
✔当記事でお伝えすること
- django-filterのパラメータについて
- リストビューでアーカイブページを簡単に作る方法
- 開発中の日報アプリへの実装方法
最終的にはこんな形になりました
既存のリストビューを拡張して、アーカイブ機能をつけています。
✔YouTube解説動画
当記事の内容は動画を見ながら進めると、約15分程度で完了します。
動画ならではの情報も解説しているので、記事と一緒にご覧ください。
動画の概要欄には、単元ごとのコードを紹介しているgithubページも載せています。
全体の流れ
アーカイブページ作成の全体の流れは以下のとおりです。
- 受け取りたいパラメータ名でフィルターフィールドを作る:filters.py
- 表示するクエリセットを返すメソッドを記述する:filters.py
- コンテキストにフィルターを渡す:views.py
- 渡されたコンテキストで一覧を表示する:*.html
- テストプログラムで検証する:tests.py
- アーカイブページへの適切なリンクを貼る:*.html
前提知識として、django-filterライブラリについてのものが必要です。
チュートリアルのひとつ前の記事で解説しています。
受け取りたいパラメータ名でフィルターフィールドを作成
django-filterライブラリを活用したフィルタークラス内に、カスタマイズしたいフィルターフィールドを作成します。
フィルターフィールドを作ることで、任意のパラメータで任意のフィルタリングができるようになります。
例えば、profileというパラメータで任意の値を受け取りたいのであれば、以下のとおりとします。
profile = django_filters.フィールドタイプ(....)
今回の日報アプリでは、プロフィールモデルのpkを受け取るので、NumberFilterを使います。
profile = django_filters.NumberFilter(method="get_profile_nippo")
パラメータを受け取り、クエリセットを返すメソッドをmethod引数で指定していきます。
表示するクエリセットを返すメソッドを記述する
クエリセットを返すためのメソッドを作成します。
ここで返されるクエリセットが、アーカイブページで表示されるようになるのです。
今回のコードはこちらです。
def get_profile_nippo(self, queryset, name, value):
from accounts.models import Profile
qs = queryset
if Profile.objects.filter(id=value).exists():
qs = qs.filter(user__profile__id=value)
else:
qs = qs.none()
return qs
各引数は以下のとおり。
- queryset:フィルタークラスに設定しているモデルクラスの一覧が格納されています
- name:パラメータ名が格納されます
- value:値が格納されます。
valueで受け取った値で、フィルタリングをおこない、対象のプロフィールクラスと紐付いている日報のみ表示するようにしています。
全体のコードはこのようになりました。
nippo > filters.py
import django_filters
from nippo.models import NippoModel
from django.forms.widgets import Select
public_choices = ((0, "全て"), (1, "公開済のみ"), (2, "ドラフトのみ"))
class NippoModelFilter(django_filters.FilterSet):
#公開・非公開を入力
public = django_filters.TypedChoiceFilter(
choices=public_choices,
method="public_chosen",
label="公開済み・下書き",
widget=Select(attrs={
"class":"form-select"
}))
#年月日によるもの
date = django_filters.TypedChoiceFilter(
method="timestamp_checker",
label="作成月",
widget=Select(attrs={
"class":"form-select"
}))
profile = django_filters.NumberFilter(method="get_profile_nippo")
class Meta:
model = NippoModel
fields = ["date", "public"]
def __init__(self, *args, **kwargs):
qs = kwargs["queryset"]
choice_option = [(obj.date.year, obj.date.month) for obj in qs]
choice_option = list(set(choice_option))
choice_option.sort(reverse=True)
DATE_OPTIONS = [
((year, month), f"{year}年{month}月") for year, month in choice_option
]
DATE_OPTIONS.insert(0, (None, "---"))
self.base_filters["date"].extra["choices"] = DATE_OPTIONS
super().__init__(*args, **kwargs)
def timestamp_checker(self, queryset, name, value):
qs = queryset
if value is not None:
year, month = eval(value)
print(year, month)
qs = queryset.filter(date__year=year).filter(date__month=month)
return qs
def public_chosen(self, queryset, name, value):
qs = queryset
if value == "1":
qs = qs.filter(public=True)
elif value == "2":
qs = qs.filter(public=False)
return qs
def get_profile_nippo(self, queryset, name, value):
from accounts.models import Profile
qs = queryset
if Profile.objects.filter(id=value).exists():
qs = qs.filter(user__profile__id=value)
else:
qs = qs.none()
return qs
コンテキストの受け渡し
views.py内のリストページで、フィルターをコンテキストで受け渡します。
すでにフィルタークラスを渡している方は不要です。
from .filters import NippoModelFilter
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["filter"] = NippoModelFilter(self.request.GET, queryset=self.get_queryset())
return ctx
受け取る側では以下のようにして受け取ります。
{% for obj in filter.qs %}
ここで展開
{% endfor %}
詳しくはこちらの記事をご覧ください。
アーカイブページのテスト関数
フィルタリングはテスト関数で試すことをおすすめします。
なぜなら手打ちではやることが多すぎて、完全な検証とはならないからです。
今回は以下のような検証手順でプログラムを作りました。
- ユーザー・プロフィールを新たに作る
- そのユーザーで一定数の日報を作る
- アーカイブページに表示されるクエリセット数と実際に作った日報数が一致するかをテスト
具体的には以下のような関数です。
#(テスト関数ではない)ユーザー&日報作成メソッド
def make_user_to_create_2_nippo(self, email, pwd="test_pass", number=2):
user_obj = User(email=email)
user_obj.set_password(pwd)
user_obj.save()
email_obj = EmailAddress(user=user_obj, email=email, verified=True)
email_obj.save()
for i in range(number):
nippo_obj = self.make_nippo(user=user_obj, public=True)
return user_obj
#(テスト関数ではない)日報リストプロフィールページから一覧を取得
def get_nippo_by_profile(self, user_obj):
self.client.force_login(user_obj)
url = reverse("nippo-list") + f"?profile={user_obj.profile.id}"
response = self.client.get(url)
filter = response.context_data["filter"]
return len(filter.qs)
#profileページのテスト
def test_profile_page(self):
#メインユーザーの日報数を数えて、表示の確認
main_user_nippo = NippoModel.objects.filter(user=self.user_obj).count()
profile_list_counter = self.get_nippo_by_profile(self.user_obj)
self.assertTrue(main_user_nippo, profile_list_counter)
#ほかのユーザーを作成してテスト(自動で2つ日報が作られる)
how_many_made_by_another = 3
another_user1 = self.make_user_to_create_2_nippo(email="abc_another1@itc.tokyo", number=how_many_made_by_another)
another_profile_list_counter = self.get_nippo_by_profile(another_user1)
self.assertTrue(how_many_made_by_another, another_profile_list_counter)
#ほかのユーザーを作成してテスト(自動で2つ日報が作られる)
how_many_made_by_another2 = 5
another_user2 = self.make_user_to_create_2_nippo(email="abc_another2@itc.tokyo", number=how_many_made_by_another2)
another_profile_list_counter2 = self.get_nippo_by_profile(another_user1)
self.assertTrue(how_many_made_by_another2, another_profile_list_counter2)
全体は以下のようになっています。
nippo > tests.py
from django.test import TestCase
from nippo.models import NippoModel
from django.urls import reverse
from django.contrib.auth import get_user_model
from allauth.account.models import EmailAddress
User = get_user_model()
class NippoTestCase(TestCase):
def __init__(self, *args, **kwargs):
self.email = "test@itc.tokyo"
self.password = "somepassword"
self.title = "testTitle1"
self.content = "testContent1"
self.slug = "some-slug-for-test"
super().__init__(*args, **kwargs)
#初期設定
def setUp(self):
user_obj = User(email=self.email)
user_obj.set_password(self.password)
user_obj.save()
email_obj = EmailAddress(user=user_obj, email=self.email, verified=True)
email_obj.save()
self.user_obj = user_obj
nippo_obj = NippoModel(user=user_obj, title=self.title, content=self.content)
nippo_obj.save()
#日報の作成ができているか
def test_saved_single_object(self):
qs_counter = NippoModel.objects.count()
self.assertEqual(qs_counter, 1)
#ユーザーが作られているか
def test_user_saved(self):
counter = User.objects.count()
self.assertEqual(counter, 1)
email_counter = EmailAddress.objects.count()
self.assertEqual(email_counter, 1)
#メールがverifiedになっているか
def test_email_verified(self):
email_obj = EmailAddress.objects.first()
self.assertEqual(True, email_obj.verified)
#ログインページが機能しているか
def test_login(self):
data = {"email": self.email, "password": self.password}
response = self.client.post("/accounts/login/", data)
self.assertEqual(response.status_code, 200)
#新規登録ページが機能しているか
def test_signup(self):
new_data = {"email": "test2@itc.tokyo", "password1": "somepassword2", "password2": "somepassword2"}
redirect_to = reverse("account_email_verification_sent")
response = self.client.post("/accounts/signup/", new_data)
#Email Confirmationへredirectされているか?
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, redirect_to)
#ユーザーが作られているか?
self.assertEqual(User.objects.count(), 2)
#queryが存在しない時に、404ページを返すかどうか
def test_response_404(self):
detail_url = reverse('nippo-detail', kwargs={"slug": "slug-not-exist"})
detail_response = self.client.get(detail_url)
update_url = reverse('nippo-update', kwargs={"slug": "slug-not-exist"})
update_response = self.client.get(update_url)
delete_url = reverse('nippo-delete', kwargs={"slug": "slug-not-exist"})
delete_response = self.client.get(delete_url)
self.assertEqual(detail_response.status_code, 404)
self.assertEqual(update_response.status_code, 404)
self.assertEqual(delete_response.status_code, 404)
#Anonymousユーザーはアクセスできない
def test_access_to_createview(self):
url = reverse("nippo-create")
redirect_to = reverse("account_login")
response = self.client.get(url)
self.assertRedirects(response, f"{redirect_to}?next=/nippo/create/")
#ログインユーザーが日報を作成する
def test_create_on_createView(self):
self.client.force_login(self.user_obj)
url = reverse('nippo-create')
create_data = {"user": self.user_obj, "title": "title_from_test", "content": "content_from_test", "slug": "abdkfs", "date":"2021-01-01"}
response = self.client.post(url, create_data)
redirect_to = reverse("nippo-list")
qs_counter2 = NippoModel.objects.count()
self.assertRedirects(response, redirect_to)
self.assertEqual(qs_counter2, 2)
#別のユーザーではアップデートできない
def test_update_with_another_user(self):
another_user = User(email="test2@itc.tokyo")
another_user.set_password("somepassword2")
another_user.save()
self.client.login(email=another_user.email, password=another_user.password)
nippo_obj = NippoModel.objects.first()
redirect_to = reverse("nippo-detail", kwargs={"slug":nippo_obj.slug})
url = reverse('nippo-update', kwargs={"slug": nippo_obj.slug})
response = self.client.get(url)
self.assertRedirects(response, redirect_to)
#自分の日報はアップデートできる
def test_update_with_own_user(self):
self.client.login(email=self.email, password=self.password)
nippo_obj = NippoModel.objects.first()
url = reverse('nippo-update', kwargs={"slug": nippo_obj.slug})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_delete_with_another_user(self):
another_user = User(email="test2@itc.tokyo")
another_user.set_password("somepassword2")
another_user.save()
self.client.login(email=another_user.email, password=another_user.password)
nippo_obj = NippoModel.objects.first()
redirect_to = reverse("nippo-detail", kwargs={"slug":nippo_obj.slug})
url = reverse('nippo-delete', kwargs={"slug": nippo_obj.slug})
response = self.client.post(url, {})
self.assertRedirects(response, redirect_to)
def test_delete_with_own_user(self):
redirect_to = reverse("nippo-list")
self.client.login(email=self.email, password=self.password)
nippo_obj = NippoModel.objects.first()
url = reverse('nippo-delete', kwargs={"slug": nippo_obj.slug})
response = self.client.post(url, {})
self.assertRedirects(response, redirect_to)
def test_listview_with_anonymous(self):
self.client.logout()#logoutの実行
url = reverse("nippo-list")
response = self.client.get(url)
object_list = response.context_data["object_list"]
self.assertEqual(len(object_list), 0)
def test_listview_with_own_user(self):
url = reverse("nippo-list")
self.client.login(email=self.email, password=self.password)
response = self.client.get(url)
object_list = response.context_data["object_list"]
self.assertEqual(len(object_list), 1)
#日報を作成する(テストではない)
def make_nippo(self, user, public):
nippo_obj = NippoModel(user=user, public=public)
nippo_obj.title = f"title {nippo_obj.pk}"
nippo_obj.content = f"content {nippo_obj.pk}"
nippo_obj.save()
return nippo_obj
#サーチフォームテスト
def test_search_queryset(self):
user_obj = User.objects.get(email=self.email)
test_user = User(email="test2@itc.tokyo")
test_user.set_password("somepassword2")
test_user.save()
obj1 = self.make_nippo(user=user_obj, public=True)
obj2 = self.make_nippo(user=user_obj, public=False)
obj3 = self.make_nippo(user=test_user, public=True)
obj4 = self.make_nippo(user=test_user, public=False)
counter = NippoModel.objects.count()
self.assertEqual(counter, 5)
self.client.login(email=self.email, password=self.password)
response = self.client.get(reverse("nippo-list"))
self.assertEqual(len(response.context_data["object_list"]), 4)
url = reverse("nippo-list") + "?search=1"
response = self.client.get(url)
self.assertEqual(len(response.context_data["object_list"]), 1)
url = reverse("nippo-list") + "?search=content"
response = self.client.get(url)
self.assertEqual(len(response.context_data["object_list"]), 4)
#SlugFieldにデフォルト値が格納されているか
def test_slug_saved(self):
nippo_obj = NippoModel.objects.first()
self.assertTrue(nippo_obj.slug)
#(テストではない)ユーザー&日報作成メソッド
def make_user_to_create_2_nippo(self, email, pwd="test_pass", number=2):
user_obj = User(email=email)
user_obj.set_password(pwd)
user_obj.save()
email_obj = EmailAddress(user=user_obj, email=email, verified=True)
email_obj.save()
for i in range(number):
nippo_obj = self.make_nippo(user=user_obj, public=True)
return user_obj
#(テストではない)日報リストプロフィールページから一覧を取得
def get_nippo_by_profile(self, user_obj):
self.client.force_login(user_obj)
url = reverse("nippo-list") + f"?profile={user_obj.profile.id}"
response = self.client.get(url)
filter = response.context_data["filter"]
return len(filter.qs)
#profileページのテスト
def test_profile_page(self):
#メインユーザーの日報数を数えて、表示の確認
main_user_nippo = NippoModel.objects.filter(user=self.user_obj).count()
profile_list_counter = self.get_nippo_by_profile(self.user_obj)
self.assertTrue(main_user_nippo, profile_list_counter)
#ほかのユーザーを作成してテスト(自動で2つ日報が作られる)
how_many_made_by_another = 3
another_user1 = self.make_user_to_create_2_nippo(email="abc_another1@itc.tokyo", number=how_many_made_by_another)
another_profile_list_counter = self.get_nippo_by_profile(another_user1)
self.assertTrue(how_many_made_by_another, another_profile_list_counter)
#ほかのユーザーを作成してテスト(自動で2つ日報が作られる)
how_many_made_by_another2 = 5
another_user2 = self.make_user_to_create_2_nippo(email="abc_another2@itc.tokyo", number=how_many_made_by_another2)
another_profile_list_counter2 = self.get_nippo_by_profile(another_user1)
self.assertTrue(how_many_made_by_another2, another_profile_list_counter2)
以上が問題なければ、アーカイブページは完成です。
ただし、リンクなどを適切に貼るために、いくつかのファイルでコードを少し追記します。
リンクを適切に表示するための変更
views.pyでは、プロフィールパラメータがあるときだけ、profileコンテキストを渡します。
views.py > NippoListView
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["filter"] = NippoModelFilter(self.request.GET, queryset=self.get_queryset())
profile_id = self.request.GET.get("profile")
q = Profile.objects.filter(id=profile_id)
if q.exists():
ctx["profile"] = q.first()
return ctx
models.py > NippoModel
日報モデル内で、リンクを生成する関数を作成します。
def get_profile_page_url(self):
from django.urls import reverse_lazy
return reverse_lazy("nippo-list") + f"?profile={self.user.profile.id}"
nippo-list.html
タイトル・リンクを貼ります。
{% extends "base.html" %}
{% block head_title %}日報アプリ一覧{% endblock %}
{% block content %}
<div class="container">
<h2 class="my-3 text-start mx-auto" style="max-width:700px;">{% if not profile %}日報リスト一覧{% else %}{{profile.username}}の日報一覧{% endif %}</h2>
</div>
<div class="container d-flex justify-content-center flex-wrap-reverse py-3">
<div class="flex-grow-1"><!-- 一覧リストdi-->
{% if filter.qs %}
{% for obj in filter.qs %}
<div class="card mx-auto mb-2" style="max-width:700px; min-width:300px;">
<div class="card-header">
{{ obj.date|date:"Y年n年j日" }}
{% if not obj.public %}
<span class="badge bg-secondary">下書き</span>
{% endif %}
</div>
<div class="card-body">
<h5 class="card-title">
{% if obj.user == request.user %}
<span class="float-end text-danger" data-bs-toggle="modal" data-bs-target="#deleteModal-{{obj.slug}}">
<i class="bi bi-trash-fill"></i>
</span>
<a href={% url "nippo-update" obj.slug %}>
{% else %}
<a href={% url 'nippo-detail' obj.slug %}>
{% endif %}
{{ obj.title }}
</a>
</h5>
<p class="card-text">
{{ obj.content }}
</p>
</div>
<div class="card-footer">
by
{% if obj.user == request.user %}
<span class="badge bg-primary">
<a class="text-light" href={{obj.get_profile_page_url}}>あなた</a>
</span>
{% else %}
<span class="badge bg-secondary">
<a class="text-light" href={{obj.get_profile_page_url}}>{{ obj.user.profile.username }}</a>
</span>
{% endif %}
</div>
</div>
{% include "nippo/nippo-delete.html" with object=obj %}
{% endfor %}
{% else %}
<div>表示する日報がありません。</div>
{% endif %}
</div><!-- 一覧リストdiv終了-->
<!-- 右側を表示する -->
{% include 'nippo/nippo-filter.html' %}
</div>
{% endblock %}
以上で完成となります。
最後に公開する前に、CSSなどを微調整していきましょう。
日報アプリの微調整
以下の2つへ変更を加えます。
- Profileクラスでアーカイブページリンクを生成するメソッド
- nav.html:ユーザー名の表記とリンク先の設定
- テンプレートフィルタの表示変更
Profileクラスでアーカイブページリンクを生成するメソッド
request.user変数から、プロフィールアーカイブページへのURLを生成できるようにします。
以下のとおりです。
accounts > models.py
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
username = models.CharField(max_length=100, verbose_name="ユーザー名")
department = models.CharField(max_length=100, blank=True, null=True, verbose_name="部署")
phone_number = models.IntegerField(blank=True, null=True, verbose_name="携帯番号")
gender = models.CharField(max_length=1, choices=GENDER_CHOICE, default=None, verbose_name="性別", blank=True, null=True)
birthday = models.DateField(blank=True, null=True, verbose_name="生年月日")
def __str__(self):
return self.username
def get_own_archive_url(self):
from django.urls import reverse_lazy
return reverse_lazy("nippo-list") + f"?profile={self.id}"
nav.html:ユーザー名の表記とリンク先の設定
nav.htmlにもいくつか変更を加えましょう。
{% load accounts_user %}
<nav class="navbar navbar-dark bg-primary">
<div class="container-fluid">
<div>
<a class="navbar-brand" href={% url 'nippo-list' %}>
<img src="/media/pages/D-Repo.png" style="width:50px;" />
</a>
</div>
<div class="flex-grow-1" style="max-width:500px;">
<form class="d-flex" method="GET">
<input class="form-control me-2" type="search" placeholder="検索..." aria-label="Search" name="search">
<button class="btn btn-outline-light" type="submit">search</button>
</form>
</div>
<div class="text-light nav-item dropdown">
<a class="nav-link dropdown-toggle text-light" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ request.user|user_display }}
</a>
<ul class="dropdown-menu endposition" aria-labelledby="navbarDropdown">
{% if request.user.is_authenticated %}
<li><a class="dropdown-item" href="{% url 'profile-update' request.user.profile.pk %}">アカウント設定</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href={{request.user.profile.get_own_archive_url}}>あなたの日報一覧</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href={% url "account_logout" %}>ログアウト</a></li>
{% else %}
<li><a class="dropdown-item" href={% url "account_login" %}>ログイン</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href={% url "account_signup" %}>サインアップ</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
accounts > templatetags > accounts_user.py:テンプレートフィルタの表示変更
from django import template
register = template.Library()
@register.filter
def user_display(user):
user_display = "ゲスト"
if user.is_authenticated:
user_display = user.profile.username
return user_display
まとめ
アーカイブページを作成する方法は以下のとおり。
- 受け取りたいパラメータ名でフィルターフィールドを作る:filters.py
- 表示するクエリセットを返すメソッドを記述する:filters.py
- コンテキストにフィルターを渡す:views.py
- 渡されたコンテキストで一覧を表示する:*.html
- テストプログラムで検証する:tests.py
- アーカイブページへの適切なリンクを貼る:*.html
django-filterを使えばさまざまなアーカイブページが簡単に作れます。
ぜひこれを応用して作ってみてください。
また、細かい微調整も重ねたので、これで一旦公開までの準備が整いました。
次回からはいよいよ、アプリの公開にむけて、サーバー構築やその通信方法などを確立していきましょう。