Wagtailのすすめ(6) カテゴリとタグを追加しよう

# はじめに

前回ブロクポストをイメージしたページクラス`PostPage`を導入したので，今回は，それにタグとカテゴリの機能を追加してみよう．これは頻出トピックで，例えば，公式ドキュメントにある[Your first Wagtail site](https://docs.wagtail.io/en/stable/getting_started/tutorial.html)や[ここのチュートリアル](https://www.accordbox.com/blog/wagtail-tutorials-building-blog-part-3/)でも扱われている．

# カテゴリモデルとタグモデルの追加

最初に，下記のように，カテゴリとタグのモデルの定義をcms/models.pyに追加し，それらを`PostPage`に紐付けする．

```python
...
from modelcluster.fields import ..., ParentalManyToManyField
from modelcluster.contrib.taggit import ClusterTaggableManager
from taggit.models import TaggedItemBase, Tag as TaggitTag

...

class PostPage(Page):
    ...
    categories = ParentalManyToManyField(
        'cms.PostCategory',
        blank=True
        )
    tags = ClusterTaggableManager(
        through='cms.PostTag',
        blank=True
        )
    ...
    content_panels = Page.content_panels + [
        ...,
        FieldPanel('categories', widget=forms.CheckboxSelectMultiple),
        FieldPanel('tags'),
    ]
    ...

...
@register_snippet
class PostCategory(models.Model):
    name = models.CharField(max_length=255)
    panels = [
        FieldPanel('name'),
    ]
    def __str__(self):
        return self.name
    class Meta:
        verbose_name = "Category"
        verbose_name_plural = "Categories"

class PostTag(TaggedItemBase):
    content_object = ParentalKey(PostPage, related_name='tagged_posts')

@register_snippet
class Tag(TaggitTag):
    class Meta:
        proxy = True
```

`PostCategory`クラスから見ていこう．これはカテゴリを表すためのもので，Djangoデフォルトの`django.db.models.Model`クラスを継承し，`name`という文字列だけをフィールドに保持したシンプルなクラスになっている．`@register_snippet`デコレータは，このクラスをスニペットとしてWagtailに登録してくれる．そして，その結果，管理サイトのスニペット編集画面でカテゴリの編集が行えるようになる．`panels`の指定はその際に用いる編集パネルの設定である．

`PostPage`クラスに追加されたフィールド`categories`を見ればわかるように，上で定義したカテゴリをページに多対多で紐付けする際には`ParentalManyToManyField`を用いればよい．また，`content_panels`の箇所では，引数に`widget=`を明示することで，チェックボックスでページとカテゴリの対応関係を指定できるようにしている．

次に，タグのモデルに目を移そう．Wagtailでは，[django-taggit](https://django-taggit.readthedocs.io/en/latest/)を用いてタグ付け機能を実装するのが標準になっている．`PostTag`クラスの定義や`tags`フィールドの`PostPage`クラスへの追加の仕方がカテゴリの場合と異なっているのはそのためである（ここでは詳細は省略する）．なお，ここまでの設定で，`PostPage`の編集画面でそのページに自由に新しいタグを追加することができるようになっているはずである　．

`TaggitTag`という名称でインポートした`taggit.models.Tag`クラスのproxyモデルとして`Tag`クラスを定義し，それに`@register_snippet`デコレータを適用しているのは，管理サイトのスニペット編集画面にカテゴリだけでなくタグも現れるようにしておくためである．これによって，スニペット編集画面を開くとそれまでに追加されたタグの一覧を確認することができるようになって便利だと思う．

この段階で管理サイトにログインし，スニペット編集画面からいくつかカテゴリを追加してみてほしい．そして，`PostPage`クラスのページの編集画面から，カテゴリを選択したり，タグを追加したりしてみよう．

# タグとカテゴリのサイドバーへの表示

続いて，カテゴリが指定されたり，タグが追加されたりした`PostPage`クラスのページを表示する際に，それらの情報がサイドバーに現れるようにしてみる．そのために，templates/cms/post_page.htmlを下のように修正した．

```jinja

{% block side_bar %}
    {{ block.super }}

    <div class="card my-4">
        <h4 class="card-header">
            Categories
        </h4>
        <div class="card-body">
            {% for category in page.categories.all %}
                <a class="btn btn-outline-primary btn-sm m-1" href="{% pageurl top_page %}category/?category={{ category }}" role="button">
                    {{ category.name }}
                </a>
            {% endfor %}
        </div>
    </div>

    <div class="card my-4">
        <h4 class="card-header">
            Tags
        </h4>
        <div class="card-body">
            {% for tag in page.tags.all %}
                <a class="btn btn-outline-primary btn-sm m-1" href="{% pageurl top_page %}tag/?tag={{ tag }}" role="button">
                    {{ tag.name }}
                </a>
            {% endfor %}
        </div>
    </div>
{% endblock %}
```
カテゴリ，タグともにBbootstrapのカード内にボタンとして表示されるようにしてあることがわかる．なお，ボタンのリンク先は，カテゴリの場合だと，

top_pageのURL/category/?category=カテゴリの文字列

タグの場合だと，

top_pageのURL/tag/?tag=タグの文字列

となっていることがわかる．ここで，これらを引き受けるためのページとして，対応する`TopPage`のページの子要素として`ListPage`クラスのページをカテゴリ用とタグ用にそれぞれ1つずつ作成しておこう（編集画面のPROMOTEタブでSlug文字列をそれぞれcategoryおよびtagに設定しておくこと）．

# 同一カテゴリ・タグを付与されたページリストの表示

続いて，上で作成した，ボタンのリンク先の`ListPage`インスタンスが表示された際に，指定されたカテゴリやタグが付与された`PostPage`のページが一覧表示されるように，`ListPage`の定義にちょとした細工を加えよう．具体的には，`get_context()`メソッドを下のように修正する．

```python
class ListPage(Page):
    ...

    def get_context(self, request):
        context = super().get_context(request)
        context['top_page'] = self.get_top_page()
        context['breads'] = self.get_breads()
        if self.related_pages.count():
            context['page_list'] = [item.page for item in self.related_pages.all()]
        else:
            tag = request.GET.get('tag')
            category = request.GET.get('category')
            if category:
                context['category'] = category
                context['page_list'] = PostPage.objects.descendant_of(self.get_top_page()).filter(categories__name=category).live().order_by('-first_published_at')
            elif tag:
                context['tag'] = tag
                context['page_list'] = PostPage.objects.descendant_of(self.get_top_page()).filter(tags__name=tag).live().order_by('-first_published_at')
            else:
                context['page_list'] = self.get_children().live().order_by('-first_published_at')
        return context
```
`if`以下で，条件に応じてコンテキストに含める`page_list`を変更していることがわかる．詳細は上のコードで確認してもらえればと思うが，ポイントは，GETメソッドのクエリに`category`や`tag`が指定されていた場合に，それが付与されている`PostPage`インスタンスのみを取得して投稿日の降順に並べ替えたものを`page_list`としている点である．また，指定されたカテゴリやタグの文字列自身もコンテキストに含めている．

したがって，上記のボタンを押して`category`や`tag`のクエリ付きのURLに飛ばされると，対応する`ListPage`がレンダリングされる前にこの修正された`get_context()`メソッドが呼ばれ，`page_list`に，指定されたカテゴリやタグが付与された`PostPage`インスタンスのみが入るので，結果としてそれらが一覧表示されることになる．

ついでに，templates/cms/list_page.htmlに下記の修正を加えて，どのようなカテゴリ，あるいはタグに対応するページが表示されているのかがわかるように，ジャンボトロンにそれらの情報を表示するようにしておこう．

```jinja
{% block header_text %}
    {{ block.super }}
    {% if category %}
        <h5>Posts in category: {{ category|upper }}</h5>
    {% elif tag %}
        <h5>Posts given tag: {{ tag|upper }}</h5>
    {% endif %}
{% endblock %}
```

# おわりに

今回は`PostPage`のページにカテゴリとタグの情報を追加する方法について簡単に見てきた，またその過程で，Wagtailのスニペットの扱い方にも触れた．

次回以降の予定は今のところ未定だが，興味深いトピックはまだまだ残っているので，機会があればいつかまた続編を書きたいと思う．

# リンク

- [Wagtailのすすめ(1) DjangoベースのCMSを比較・検討してWagtailを選択した話](https://qiita.com/j54854/items/96396f24fe8ce9b60d05)
- [Wagtailのすすめ(2) ページを定義し，編集し，表示してみよう](https://qiita.com/j54854/items/3dcc7f6ddf54daf1a6b6)
- [Wagtailのすすめ(3) ページのツリー構造を理解し，利用しよう](https://qiita.com/j54854/items/f8c2cd6fb02bbc36a43d)
- [Wagtailのすすめ(4) テンプレートにコンテキストを渡そう](https://qiita.com/j54854/items/c5ae6b0d6e3520af8999)
- [Wagtailのすすめ(5) StreamFieldに独自ブロックを追加しよう](https://qiita.com/j54854/items/7e51a5f485c41d9c22ae)