Wagtailのすすめ(3) ページのツリー構造を理解し，利用しよう

# はじめに

Wagtailの管理サイトでは，既存のページにADD CHILD PAGEすることで新しいページインスタンスを作成する．例えば，前回は，デフォルトで用意されてる`HomePage`クラスのインスタンス（"Welcome to your new Wagtail site!"ページ）の子要素として，`TopPage`クラスのインスタンスを1つ生成したことを思い出そう．

この結果，（デフォルトの`HomePage`インスタンス以外の）すべてのページインスタンスはそれぞれ，ただ1つの親要素をもつことになる．また，各ページインスタンスは複数の子要素をもつことができる．このように，Wagtailの中のページインスタンスはすべてツリー構造で規定される親子関係で互いに結ばれていると言える．

各ページのURLもこのツリー構造に従って自動的に付与される（ので，urls.pyでのルーティングの指定は不要になる）．例えば，デフォルトの`HomePage`インスタンスのアドレスが，初期設定の

https://ホスト名/

のままであるとすると，その子要素として作成されたある`TopPage`インスタンスのアドレスは，

https://ホスト名/hello/

となる（helloはこのページのSlugに指定されている文字列）．さらに，その子要素として生成された別のページのSlugの文字列がworldだったとすると，そのページのアドレスは，

https://ホスト名/hello/world/

となるわけである．

今回は，まず`PlainPage`クラスを追加した後，管理サイトでページインスタンスを増やし，このツリー構造を実感してみるとともに，それを利用したちょっとしたテクニックを試してみよう．

# ページクラスの追加

最初に，前回の`TopPage`に加えて，`PlainPage`の定義を追加する．具体的には，cms/models.pyを下のように拡張した．

```python
from django.db import models

from modelcluster.fields import ParentalKey

from wagtail.core.models import Page, Orderable
from wagtail.core.fields import RichTextField
from wagtail.admin.edit_handlers import FieldPanel, InlinePanel, PageChooserPanel
from wagtail.images.edit_handlers import ImageChooserPanel

class TopPage(Page):
    ...

class PlainPage(Page):
    cover_image = models.ForeignKey(
        'wagtailimages.Image',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+'
    )
    intro = models.CharField(max_length=255)
    main_body = RichTextField(blank=True)

    content_panels = Page.content_panels + [
        ImageChooserPanel('cover_image'),
        FieldPanel('intro'),
        FieldPanel('main_body', classname="full"),
    ]

    parent_page_types = ['cms.TopPage']
    subpage_types = []

    def get_top_page(self):
        pages = self.get_ancestors().type(TopPage)
        return pages[0]
```

`cover_image`，`intro`，`main_body`は`TopPage`にもあったフィールドなので，`content_panels`までは特に説明の必要はないだろう．`parent_page_types`と`subpage_types`は親子関係でこのメージクラスと結びつけることのできるページクラスを限定するための記述である．具体的には，`PlainPage`の親要素は`TopPage`でなければならないことと，子要素をもつことはできないことを指定している．

`get_top_page()`は，自分の親要素である`TopPage`インスタンスを返す自作メソッドである．この自作メソッドの中で`get_ancestors()`というメソッドを利用しているが，これはWagtailが用意してくれているページインスタンス間の親子関係を利用して他のページを取得するメソッドの1つである．他にも類似のメソッドが用意されているので，[ここ](https://docs.wagtail.io/en/stable/reference/pages/queryset_reference.html)で確認してみてほしい．

これで`PlainPage`クラスが定義できたので，管理サイトにログインして，前回作成した`TopPage`インスタンスの子要素として`PlainPage`インスタンスをいくつか生成しておこう（それらを後で利用する）．

# ナビゲーションアイテムの保持

新しいページクラスを定義し，そのインスタンスをいくつか作成したので，次にそれらをブラウザに表示させてみたくなるが，その前に，`TopPage`クラスの方にも少し拡張を加えておこう．前回ダミーのまま放置しておいたnav_barブロックとfooterブロックをそれぞれ実装するための準備である．具体的には，下記のようにコードを拡張した．

```python
class TopPage(Page):
    ...
    footer_text = models.CharField(blank=True, max_length=255)

    content_panels = Page.content_panels + [
        ...,
        FieldPanel('footer_text'),
        InlinePanel('nav_items', label="Nav items"),
    ]

    def get_top_page(self):
        return self

class PlainPage(Page):
    ...

class NavItems(Orderable):
    top_page = ParentalKey(TopPage, related_name='nav_items')
    label = models.CharField(max_length=255)
    page = models.ForeignKey(
        Page,
        on_delete=models.CASCADE,
        related_name='+'
    )
    panels = [
        FieldPanel('label'),
        PageChooserPanel('page'),
    ]
```
`TopPage`クラスに追加した`footer_text`はフッターに表示するテキストを格納するためのものである．`content_panels`を見ると，この`footer_text`のためのパネルに加えて，`nav_items`のための`InlinePanel`が追加されている．これは何だろうか．

これを理解するためには，下の`NavItems`クラスの定義を参照する必要がある．まず，`NavItems`クラスは`Orderable`クラスを継承したサブクラスとして定義されていることがわかる．`Orderable`クラスは，ページの部品をページとは独立に定義し，多対一でページに紐付けできるようにするためにWagtailが用意してくれているクラスである．ここでは，これをナビゲーションバーに設置するアイテム（リンク先ページ`page`とそのラベル`label`）を管理するために用いているわけである．

各ナビゲーションアイテムのリンク先ページ`page`は，`models.ForeignKey`で`Page`クラスに紐付けすればよい．そしてその編集には`PageChooserPanel`を用いる．なお，`Orderable`クラスでは編集パネルの指定は`content_panels`ではなく`panels`で指定することになるので注意しておこう．

この`NavItems`クラスは`TopPage`クラスに部品として紐付けするわけであるが，これには`top_page`フィールドに記述されているように，django-modelclusterの`ParentalKey`を用いる．そうすると，`TopPage`クラスの編集画面で，`contant_panels`に追加された`InlinePanel`を用いて，`NavItems`クラスのインスタンスが編集できるようになるという仕組みである．

`TopPage`クラスにも`get_top_page()`メソッドが追加されていることにも注意しておこう．これは`TopPage`クラスの場合は自分自身を返すメソッドになる．なぜこのようなメソッドをわざわざ追加するのかは下で明らかになる（ので，少し我慢して読み進めてほしい）．

この段階で，もう一度，管理サイトにログインして，前回作成した`TopPage`インスタンスの編集画面を開き，`footer_text`の文字列とともに，`NavItems`クラスのインスタンスをいくつか追加しておこう．

# ナビゲーションバーとフッターの作成

これで準備が整ったので，ページの表示に進む．最初に，新しく作成した`PlainPage`を表示させてみよう．このクラスのためのテンプレートtemplates/cms/plain_page.htmlを下のように定義した．

```jinja
{% extends 'cms/base.html' %}
{% load static wagtailcore_tags wagtailimages_tags %}

{% block nav_bar %}{% endblock %}

{% block header %}
    {% if page.cover_image %}
        {% image page.cover_image fill-1000x400 as my_image %}
    {% else %}
        {% image page.get_top_page.specific.cover_image fill-1000x400 as my_image %}
    {% endif %}
    <div class="jumbotron jumbotron-fluid" style="background-image: url('{{ my_image.url }}');">
        <div class="container">
            <h1 class="display-4">{{ page.title }}</h1>
            <p class="lead">{{ page.intro }}</p>
        </div>
    </div>
{% endblock %}

{% block main %}
    <div class="container">
        <div class="row">
            <div class="col-md-8">
                {% block main_body %}
                    <div class="rich-text my-5">
                        {{ page.main_body|richtext }}
                    </div>
                {% endblock %}
            </div>
            <div class="col-md-4">
                {% block side_bar %}
                    <div class="card my-4">
                        <h4 class="card-header">
                            {{ page.get_top_page.specific.side_title}}
                        </h4>
                        <div class="card-body">
                            {% image page.get_top_page.specific.side_image fill-200x200 class="img-fluid rounded-circle d-block mx-auto" alt="" %}
                        </div>
                        <div class="card-body">
                            <div class="rich-text">
                                {{ page.get_top_page.specific.side_body|richtext }}
                            </div>
                        </div>
                    </div>
                {% endblock %}
            </div>
        </div>
    </div>
{% endblock %}

{% block footer %}
    <nav class="navbar navbar-dark bg-dark fixed-bottom">
        <span class="navbar-text mx-auto py-0">
                {{ page.get_top_page.specific.footer_text }}
        </span>
    </nav>
{% endblock %}

{% block extra_js %}
    {{ block.super }}
    <script type="text/javascript" src="{% static 'js/navbar.min.js' %}"></script>
    <script>
        const nav_props = {
            color: "dark",
            variant: "dark",
            brandlabel: "{{ page.get_top_page.specific.title }}",
            brandhref: "{% pageurl page.get_top_page %}",
            navs: [
                {% for nav_item in page.get_top_page.specific.nav_items.all %}
                    {
                        label: "{{ nav_item.label }}", 
                        href: "{% pageurl nav_item.page %}"
                    },
                {% endfor %}
            ],
        };

        ReactDOM.render(
            React.createElement(MyNavbar, nav_props),
            document.getElementById('nav_bar')
            );
    </script>
{% endblock %}
```
headerブロックから見ていこう．基本的には前回のtemplates/cms/top_page.htmlの記述と同じであるが，背景画像`my_image`として，もしこの`PlainPage`インスタンス自体に`cover_image`が登録されていなければ，`get_top_page()`メソッドで取得した親要素の`TopPage`インスタンスの`cover_image`を利用するように工夫されていることがわかる．

また，その際に，`page.get_top_page.cover_image`ではなく，`page.get_top_page.specific.cover_image`となっているのが気になったかもしれない．コード全体を眺めると，他にも`.specific`が追加されている箇所が散見される．この`.specific`とは何だろうか．

実は，`get_top_page()`メソッド（の中の`get_ancestors()`メソッドなど）で他のページインスタンスを取得した場合，デフォルトでは，`Page`クラス（今の場合だと`TopPage`ではなく，そのスーパークラス）のインスタンスが参照されるようになっているらしい．したがって，そのままでは，サブクラスで追加された属性にはアクセスできないことになるのだが，`.specific`をつけることでそれが可能になるというトリックになっている．

次に，mainブロックの中のside_barブロックや，footerブロックのコードを見てみると，それぞれ対応する`TopPage`インスタンスから必要な情報を取得してきてレンダリングしていることがわかる．

最後に，nav_barブロックに目を移そう．このブロックの中身が空になっているのは，この`<div id="nav_bar">`のレンダリングをReactとReact Bootstrapに任せるようにしたからである．具体的なスクリプトはextra_jsのブロックに記載されている．上で用意した`nav_items`が利用されていることや，Wagtailのpageurlタグでリンク先ページのURLを取得できることなども確認できる．

なお，このブロックの頭で読み込んでいるjs/navbar.min.jsは，JSXを用いて作成した下記のReactコードをコンパイル・圧縮したものである．

```javascript
var Navbar = ReactBootstrap.Navbar;
var Nav = ReactBootstrap.Nav;

function MyNavbar (props) {
  return (
    <Navbar bg={props.color} variant={props.variant} fixed="top" expand="sm">
      <Navbar.Brand href={props.brandhref}>{props.brandlabel}</Navbar.Brand>
      <Navbar.Toggle/>
      <Navbar.Collapse>
      <Nav className="ml-auto">
        {props.navs.map((nav) => (
          <Nav.Link href={nav.href}>{nav.label}</Nav.Link>
        ))}
      </Nav>
      </Navbar.Collapse>
    </Navbar>
  );
};
```

これで`PlainPage`クラスのページも無事に表示できるようになった．

最後に，上で`TopPage`クラスを拡張したので，そちらの方のテンプレートtemplates/cms/top_page.htmlもそれに応じて修正したいところである．実は，そのためにこのファイルにわざわざ多くのコードを書き足す必要はない．むしろ，下のように大幅に単純化するだけでOKだ．

```html
{% extends 'cms/plain_page.html' %}
```
すなわち，`TopPage`クラスのページにも`PlainPage`クラスのためのテンプレートをそのまま利用することができる．そう，`TopPage`クラスに一見無駄なように見える`get_top_page()`メソッドを追加したのは，このためだったというわけだ．

# おわりに

今回は，Wagtailの管理下にあるページがツリー構造にまとめられていることを理解した上で，それを利用するテクニックの一端に触れてみた．また，`Orderable`クラスを利用してページの部品を定義することも試した．

次回は，`ListPage`クラスを追加するとともに，それに関連してまたちょっとしたテクニックにも触れてみたい．

# リンク

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