Django、ブログ②の記事一覧を作る

Python Django

概要

Djangoで、ブログを作るシリーズ②の一つです。記事一覧を作っていきます。

記事一覧ビューの作成

まずは、シンプルな記事一覧機能から作りましょう。urls.pyに追加しまして...

path('', views.NoteList.as_view(), name='note_list'),

views.pyに、次のようなビューを作ります。

from django.views import generic
from .models import Note


class NoteList(generic.ListView):
    model = Note

本当にシンプルなListViewです。説明は要らないと思われます。

テンプレートファイルを作りましょう。note_list.htmlです。

{% extends 'nblog2/base.html' %}
{% load humanize %}

{% block content %}
    <div id="menu">
        <h1 class="container">全てのノート</h1>
    </div>
    <section id="list" class="container">
        {% for note in note_list %}
            <a class="box" href="">
                <article class="box-inner">

                    <img class="thumbnail" src="{{ note.thumbnail.url }}" alt="{{ note.title }}">

                    <div class="mask">
                        <div class="caption">Read More</div>
                    </div>

                    <div class="meta">
                        <h2 class="title">{{ note.title }}</h2>

                        <p class="category">{{ note.category }}</p>
                        <p class="date">{{ note.created_at }}(
                            <time class="updated_at"
                                  datetime="{{ note.updated_at | date:'Y-m-d' }}">{{ note.updated_at | naturaltime }}に更新
                            </time>
                            )
                        </p>
                    </div>

                </article>
            </a>
        {% empty %}
            <p class="box">検索結果はありませんでした。</p>
        {% endfor %}
    </section>
{% endblock %}

{% load humanize %}して使っているnaturaltimeは、日付型のデータを渡すと「〇時間前」といった人にやさしい表現で返してくれるフィルタです。Djangoに付属しているアプリケーションで、これを利用するにはsettings.pyINSTALLED_APPSdjango.contrib.humanizeという記述を足しておきましょう。

そして、style.cssに記事一覧に関するスタイルを追記します。

/* ヘッダー下メニュー */
#menu {
  margin-bottom: 100px;
  text-align: center;
}

#menu a {
  color: #333;
  font-weight: bold;
}

/* 一覧 レイアウト */
@media (min-width: 1024px) {
  #list {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-column-gap: 50px;
    column-gap: 50px;
    justify-items: center;
  }
}

@media (min-width: 1366px) {
  #list {
    grid-template-columns: 1fr 1fr 1fr;
  }
}


/* 一覧の各ノート */
.box {
  margin-bottom: 100px;
  display: block;
}

.box:hover {
  text-decoration: none;
}

.box-inner {
  display: grid;
  grid-template-rows: auto auto;
}

.thumbnail {
  grid-column: 1;
  grid-row: 1;
}

@media (min-width: 1024px) {
  .thumbnail {
    width: 437px;
    height: 273px;
    object-fit: cover;
  }
}

@media (min-width: 1366px) {
  .thumbnail {
    width: 388px;
    height: 242px;
  }
}


.meta {
  color: #666;
  text-align: center;
  grid-column: 1;
  grid-row: 2;
}

.title {
  margin: 1em 0;
  color: #333;
}

.category {
  margin-bottom: .5em;
}

.mask {
  opacity: 0;
  background: rgba(0, 0, 0, 0.3);
  transition: 0.5s;

  grid-column: 1;
  grid-row: 1;
  display: grid;
  justify-items: center;
  align-items: center;
}

.box:hover .mask {
  opacity: 1;
}

.caption {
  font-weight: 700;
  color: #fff;
  letter-spacing: 1px;
  border: solid 1px #fff;
  padding: 0.5em 1em;
}

ここまでで、次のような見た目になります。

検索機能の作成

次に、検索機能を実装します。まずはforms.pyを作ります。

from django import forms
from django.db.models import Count
from .models import Category, Note


class NoteSearchForm(forms.Form):
    """検索フォーム"""
    key_word = forms.CharField(
        label='キーワード',
        required=False,
        widget=forms.TextInput(attrs={'placeholder': '検索キーワード'})
    )

    category = forms.ModelChoiceField(
        label='カテゴリ',
        required=False,
        queryset=Category.objects.annotate(note_count=Count('note')).order_by('name'),
        widget=forms.RadioSelect,
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        all_note_count = Note.objects.filter(is_public=True).count()
        self.fields['category'].empty_label = f'全カテゴリ({all_note_count})'

よくある検索フォームなのですが、カテゴリフィールドは少しややこしいことになっています。

widget=forms.RadioSelect,は、デフォルトのselect要素ではなく、ラジオボタンに変更しています。見た目的に、今回はラジオボタン形式を使いたかったので。

queryset=Category.objects.annotate(note_count=Count('note')).order_by('name'),は何かというと、各カテゴリモデルインスタンスに、note_countという属性名で、紐づく記事数が設定されるようになります。イラスト(10)みたいに表示するための処理です。これは後程、説明します。

__init__メソッドでやっているのは、デフォルトで作られる「----------」といった空を表す選択肢...empty_labelですが、これを書き換えて、「全てのカテゴリ(100)」みたいに表示しています。数字部分は、全ての公開記事の数です。

models.pyのCategoryモデル、__str__メソッドを次のように変更します。

class Category(models.Model):
    name = models.CharField('カテゴリ名', max_length=100)

    def __str__(self):
        if hasattr(self, 'note_count'):
            return f'{self.name}({self.note_count})'
        return self.name

フォーム内の選択肢ですが、モデルの__str__が呼ばれます。なのでフォームの選択肢をイラスト(10)みたいな表示にしたい場合は、ここも編集する必要があります。note_countという属性がある場合、つまりフォーム側から呼ばれた選択肢の作成処理の場合は、イラスト(10)といった文字列を返すようにしておきます。

views.pyです。先ほど作ったフォームを使うようにしましょう。

from django.db.models import Q
from django.views import generic
from .forms import NoteSearchForm
from .models import Note


class NoteList(generic.ListView):
    model = Note
    queryset = Note.objects.filter(is_public=True)
    ordering = '-created_at'

    def get_queryset(self):
        queryset = super().get_queryset()
        form = self.form = NoteSearchForm(self.request.GET or None)
        if form.is_valid():
            category = form.cleaned_data.get('category')
            if category:
                queryset = queryset.filter(category=category)

            key_word = form.cleaned_data['key_word']
            if key_word:
                queryset = queryset.filter(Q(title__icontains=key_word) | Q(text__icontains=key_word)).distinct()

        return queryset.select_related('category')

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

get_querysetメソッドで、フォームの入力内容をもとに、絞り込んでいます。

この際にself.formといった属性にフォームオブジェクトを設定しています。これをしておくことで、get_context_dataメソッドでcontext['search_form'] = self.formといった書き方ができます。違う言い方をすると、そうしなかった場合はget_context_dataメソッド内でもcontext['search_form'] = NoteSearchForm(self.request.GET or None)といった書き方をしなくてはならなくて、無駄な処理が増えます。必ずget_querysetが先に呼ばれるので、問題が起きたりもしません。

post_list.htmlに、aside要素部分を追加します。</section>の後に追加しましょう。

    </section>

    <aside id="search" class="container">
        <h2>Search</h2>
        <form action="" method="GET" id="search-form">
            {{ search_form.category }}

            <div class="field">
                {{ search_form.key_word }}
            </div>

            <div class="field">
                <button type="submit">送信</button>
            </div>
        </form>
    </aside>

{% endblock %}

style.cssに、フォーム関連のスタイルを追記します。

/* 検索フォーム */
#search {
  text-align: center;
  margin-top: 200px;
}

#search > h2 {
  margin-bottom: 1em;
  letter-spacing: 1px;
}

#search-form ul {
  list-style-type: none;
  margin-bottom: 1em;
}

#search-form ul li {
  display: inline-block;
  margin-right: 1em;
}

.field {
  max-width: 200px;
  margin: 0 auto 1em auto;
}

input[type="text"] {
  width: 100%;
  padding: 6px 12px;
  box-sizing: border-box;
  border-radius: 4px;
  border: solid 1px #333;
}

button {
  width: 100%;
  padding: 6px 12px;
  box-sizing: border-box;
  border-radius: 4px;
  background-color: transparent;
  border: solid 1px #333;
  cursor: pointer;
}

button:hover {
  background-color: #333;
  color: #fff;
  transition: all 0.2s ease-out;
}

見出し部分を動的にする

「全てのノート」という部分ですが、ここは検索した内容で動的にしようと思います。例えば「Webデザイン」のカテゴリをチェックして検索したら「Webデザインの検索結果」みたいに表示させようと思います。

まず、ビューを変更します。

class NoteList(generic.ListView):
    model = Note
    queryset = Note.objects.filter(is_public=True)
    ordering = '-created_at'

    def get_queryset(self):
        self.category = self.key_word = ''
        queryset = super().get_queryset()
        form = self.form = NoteSearchForm(self.request.GET or None)
        if form.is_valid():
            category = self.category = form.cleaned_data.get('category')
            if category:
                queryset = queryset.filter(category=category)

            key_word = self.key_word = form.cleaned_data['key_word']
            if key_word:
                queryset = queryset.filter(Q(title__icontains=key_word) | Q(text__icontains=key_word)).distinct()

        return queryset.select_related('category')

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        heading = ''
        if self.category:
            heading += '「{}」'.format(self.category.name)
        if self.key_word:
            heading += '「{}」'.format(self.key_word)
        if heading:
            heading += 'の検索結果'
        else:
            heading = '全てのノート'
        context['heading'] = heading
        context['search_form'] = self.form
        return context

get_context_dayaメソッドの中で、「全てのノート」とか「〇〇の検索結果」みたいな文字列を作っています。また、入力・選択されたキーワードやカテゴリを取得するために、get_querysetメソッドの中で、self.category = ...みたいに、属性に設定したりもしています。

note_list.htmlを少し変更します。

    <div id="menu">
        <h1 class="container">{{ heading }}</h1>
    </div>

「全てのノート」という文字列を直接書いていたので、それを{{ heading }}に変更しました。

ページング機能の作成

記事を一度に12件ずつ表示するようにしましょう。

class NoteList(generic.ListView):
    model = Note
    queryset = Note.objects.filter(is_public=True)
    paginate_by = 12  # 追加
    ordering = '-created_at'

ListViewでは、これだけでページング処理をしてくれます。しかし、今回は検索フォームが既にあります。Django、ページング処理まとめでも解説していますが、他のGETパラメータあがる場合は、ページング処理が少し面倒になります。

アプリケーション内にtemplatetagsパッケージを作り、中にnblog2.pyを作ります。中身は次のようにしておきます。

from django import template
register = template.Library()


@register.simple_tag
def url_replace(request, field, value):
    """GETパラメータの一部を置き換える。"""
    url_dict = request.GET.copy()
    url_dict[field] = str(value)
    return url_dict.urlencode()

そして、これを使ってページネーションのHTMLを作ります。note_list.htmlに追記しましょう。3行目と、</section>の手前にコードを追加します。

{% extends 'nblog2/base.html' %}
{% load humanize %}
{% load nblog2 %}   {# ←追加 #}

{# 略 #}

        <nav id="page">
            {% for num in page_obj.paginator.page_range %}
                {% if page_obj.number == num %}
                    <span class="page current">{{ num }}</span>
                {% else %}
                    <a href="?{% url_replace request 'page' num %}" class="page">{{ num }}</a>
                {% endif %}

            {% endfor %}
        </nav>
    </section>

style.cssにも、追記します。

/* ページネーション部分 */
#page {
  grid-column: 1 / -1;
  justify-self: center;
  text-align: center;
}

#page > * {
  display: inline-block;
  margin-right: 1em;
  padding: 10px;
}

#page.current {
  color: #333;
}

非公開記事一覧ビューの作成

まず、urls.pyに追加します。

path('private/', views.PrivateNoteList.as_view(), name='private_note_list'),

そして、is_publicがFalseな記事だけ表示するビューを作りましょう。

from django.contrib.auth.mixins import LoginRequiredMixin  # 追加


# 略
class PrivateNoteList(LoginRequiredMixin, NoteList):
    queryset = Note.objects.filter(is_public=False)
    raise_exception = True

NoteListビューを継承して、殆どの処理を引き継ぎます。queryset属性だけ上書きして、非公開記事を取得するように変更します。

非公開記事なので、LoginRequiredMixinを使い、ログインしないと非公開記事一覧は見れないようにしています。raise_exception = Trueなので、非公開記事一覧ページにアクセスしたけど、ログインしていない場合は403ページに直行します。

ちなみにデフォルトはFalseで、403ではなくログインページにリダイレクトします。ログインページの存在そのものを隠したいという場合は、今回のようにTrueにしておくと良いでしょう。

これで/privateにアクセスすると、非公開記事一覧ページが表示されるようになります。今回はやりませんでしたが、ログインしている場合はページ上部にでも、非公開記事一覧ページへのリンクを表示する、等も良いでしょう。

Relation Posts

Comment

記事にコメントする

まだコメントはありません。