Wagtailのすすめ(2) ページを定義し，編集し，表示してみよう

# はじめに

今回から数回に分けて，実際にWagtailで簡単なCMSを構築していってみようと思う．ゴールとしては，複数の研究プロジェクトや研究グループのページをホストするサイトをイメージしてみた．

各研究プロジェクトや研究グループのトップページを`TopPage`と名付けよう．その下には，プロジェクトの概要説明，メンバー紹介などのページなどを置くことになりそうだが，それらのために使うページを`PlainPage`と呼ぶことにする．さらに，ブログポスト的なページ`PostPage`と，関連するブログポストをまとめてリスト表示するページ`ListPage`を用意することにしよう．

ひとまず，デフォルトで用意されている`HomePage`はそのままにしておいて，その下に複数の`TopPage`をぶら下げられるようにする．そして，`TopPage`の下には`PlainPage`，もしくは`ListPage`をぶら下げられるようにし，`ListPage`の下にはさらに別の`ListPage`，あるいは`PostPage`をぶら下げられるようにする．

今回は，`TopPage`を題材にして，実際にページを定義し，編集し，それを表示させるまでの大まかな流れをみていこう．

# ページの定義

上で導入した`HomePage`，`TopPage`，`PlainPage`，`ListPage`，`PostPage`などはCMSで管理するページの種類を表している．Wagtailでは，これらのページを，`Page`クラスを継承したサブクラスとして定義していく．

デフォルトで用意されている`HomePage`の定義はhome/models.pyに下記のように記述されている．

```python
from wagtail.core.models import Page

class HomePage(Page):
    pass
```

すなわち，`HomePage`は，デフォルトでは`Page`クラスに何も追加されていない．

スーパーユーザでWagtailの管理サイトにログインすると，"Welcome to your new Wagtail site!"というタイトルのページが1つだけ登録済みになっているはずである．これは，`HomePage`クラスのインスタンスに対応している．このページの編集画面を開いてみると，CONTENT，PROMOTE，SETTINGSという3つのタブがあり，各タブからそれぞれいくつかの情報を追加できるようになっている．これらの情報は，`Page`クラスが保持できる情報の例である．

自作のページを定義していく際には，`Page`クラスを継承することによって，`Page`クラスにもともと用意されている以外の情報（フィールドやメソッド）を追加していくことになる．この際に検討すべきことは次の2つである．

- そのページのために編集者が管理サイトから投入すべき情報はなにか？
- そのページを表示する（レンダリングする）ためにテンプレートに渡すべき情報はなにか？

これらの項目への答えは必ずしも一致しない．後者の項目の一部は，編集者が投入しなくとも，別の方法で自動的に取得できる可能性があるからである．ここではまずは，前者の項目に対応する情報をフィールドとして`Page`クラスに追加した上で，管理サイトからそれらを投入できるように編集画面を拡張していこう．

自作ページの定義は，前回追加したcmsアプリケーションの中のmodelモジュール，すなわちcms/models.pyの中に記述していく．ここでは，下のように`TopPage`クラスを定義した．

```python
from django.db import models
from wagtail.core.models import Page
from wagtail.core.fields import RichTextField
from wagtail.admin.edit_handlers import FieldPanel
from wagtail.images.edit_handlers import ImageChooserPanel

class TopPage(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)
    side_image = models.ForeignKey(
        'wagtailimages.Image',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+'
    )
    side_title = models.CharField(blank=True, max_length=255)
    side_body = RichTextField(blank=True)

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

`intro`（ページの紹介文の情報），`side_title`（サイドバーのタイトル）の2つがDjangoの`models.CharField`として追加されていることがわかる．また，Wagtail独自のフィールドである`RichTextFIeld`を用いて，`main_body`（ページの本文）と`side_body`（サイドバーの本文）の2つが追加されている．`RichTextFIeld`は，リッチテキスト形式で編集画面から投入した文書を，適切なhtmlタグ付きでテンプレートに渡すためのものであり，CMSには欠かせないフィールドであると言える．

また，`cover_image`や`side_image`を見ると，画像の情報を追加する方法もわかるだろう．具体的には，Djangoの`models.ForeignKey`を利用して`wagtailimages.Image`クラスと紐付けすればよい．

最後に`content_panels`のリストに，新たに追加したフィールドに関するパネルの情報が追加されていることも見て取れる．これは，管理サイトのページ編集画面のCONTENTタブから，それらのフィールドに関する情報を投入するための入力フォームの形式を指定するものである．画像については`ImageChooserPanel`，それ以外には`FieldPanel`が指定されている（ひとまず，画像以外は`FieldPanel`を指定しておけば，フィールドのタイプに応じて適切な入力フォームが用意されると考えておけばいいだろう）．

# ページの編集

続いて，上で定義したページを管理サイトのページ編集画面で編集してみよう．そうすることで，`content_panels`に追加した指定が編集画面にどのように反映されたのかもわかる（例えば，`classname="full"`の指定を外すと編集画面がどう変わるかなどを確めてみるといいだろう）．

具体的には，ページ一覧から先ほどの"Welcome to your new Wagtail site!"を選択し，EDITではなく，ADD CHILD PAGEをクリックし，`TopPage`を選択すれば，新しい`TopPage`インスタンスの編集画面が開く．編集自体は直感的に行えるので説明は不要と思う．自由に情報を追加してサンプルとなるインスタンスを作成してみよう．

なお，PROMOTEタブのSlugには自動生成された文字列が入るが，これを変更すれば，このページが公開される際のURL（の末尾）を変更することができる．

# ページの表示

以上で，自作の`TopPage`クラスを定義し，そのインスタンスを生成するところまで進んだ．次に，これをブラウザで表示させてみることにしよう．ページのURLは上のSlagの文字列に基づいて決まる．

通常のDjangoアプリケーションの場合，所定のアドレスにアクセスするとurls.pyでそれに紐付けられたview関数が呼ばれる．そして，view関数が適切なcontextを生成しそれをテンプレートに組み込むことによってページがレンダリングされるという流れが一般的である．したがって，ページを表示できるようにするためには，urls.pyにルーティングの指定を追加し，view関数とテンプレートを用意する必要があった．

これに対して，Wagtailでは，ルーティングは自動的に行われるためurls.pyの編集は不要であり，標準的な使い方では，view関数のようなものも用意する必要はない．テンプレートだけ準備しておけば，自動的にそれに従ってインスタンスの情報を元にページのレンダリングが行われる仕組みになっている．

ということで，まずはテンプレートを用意しよう．cms/models.pyに定義された`TopPage`クラスのページが利用するテンプレートは，デフォルトではtemplates/cms/top_page.htmlという名称になる（クラス名称のパスカルケースがテンプレートではスネークケースになる点に注意しよう）．通常はそのままデフォルトの名称を利用すればいいだろう．

templates/cms/top_page.htmlをゼロから作成するのは大変なので，Wagtailがデフォルトで用意してくれているtemplates/base.htmlから継承していくことにする．templates/base.htmlには，スタイルファイルを追加するためのextra_css，コンテンツを追加するためのcontent，javaScriptを追加するためのexstra_jsというブロックが含まれているので，それらのブロックを利用する．

テンプレートの継承関係の見通しをよくするために，まずtemplates/cms/base.htmlを次のように作成した．

```jinja
{% extends 'base.html' %}
{% load static %}

{% block extra_css %}
    <link
    rel="stylesheet"
    href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
    integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk"
    crossorigin="anonymous"
    />
    <link rel="stylesheet" type="text/css" href="{% static 'css/cms/base.css' %}">
{% endblock %}

{% block content %}
    <div id="nav_bar">{% block nav_bar %}{% endblock %}</div>
    <div id="header">{% block header %}{% endblock %}</div>
    <div id="main">{% block main %}{% endblock %}</div>
    <div id="footer">{% block footer %}{% endblock %}</div>
{% endblock %}

{% block extra_js %}
    <script src="https://unpkg.com/react@17/umd/react.production.min.js" crossorigin></script>
    <script src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js" crossorigin></script>
    <script
    src="https://unpkg.com/react-bootstrap@next/dist/react-bootstrap.min.js"
    crossorigin></script>
{% endblock %}
```

extra_cssのブロックでは，Bootstarapのためのcssと，自作のcss/cms/base.cssを読み込んでいる．contentブロックでは，`<body>`タグの中身をさらにnav_bar，header，main，footerというidの4つの`<div>`に細分している．また，extra_jsのブロックでは，ReactとReact Bootstrapのためのスクリプトを読み込んでいる（React Bootstrapを使うのは，他の箇所でReactを利用したいからである．単に，Bootstrapを使うだけなら，普通にjQueryを取り込めばよいだろう）．

なお，自作のcssの記述は最小限に抑えた．css/cms/base.cssの中身は下記の通りである．

```css
body {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  padding: 50px 0 50px 0;
}

.jumbotron {
  height: 400px;
  color: #ffffff;
  background-size: cover; 
  background-position: center center;
  background-color: rgba(0, 0, 0, 0.5); 
  background-blend-mode: multiply;
}

#main > .container {
  max-width: 992px;
}

#footer {
  font-size: 90%;
}

/* これ以下の記述は，埋込みメディアをレスポンシブルにするための指定 */

.rich-text img {
    max-width: 100%;
    height: auto;
}

.responsive-object {
    position: relative;
}

.responsive-object iframe,
.responsive-object object,
.responsive-object embed {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
}
```

後半の記述は画像やその他の埋込みメディアをレスポンシブルにするための指定である（詳しくは[ここ](https://docs.wagtail.io/en/stable/topics/writing_templates.html)を参照）．あわせて，config/settings/base.pyに以下の記述を追加しておく．

```python
WAGTAILEMBEDS_RESPONSIVE_HTML = True
```

続いて，これらを元に，templates/cms/top_page.htmlを下のように作成した．

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

{% block nav_bar %}
    <nav class="navbar fixed-top navbar-dark bg-dark mb-0">
        <span class="navbar-text mx-auto">
            Say something at the begining.
        </span>
    </nav>
{% endblock %}

{% block header %}
    {% image page.cover_image fill-1000x400 as my_image %}
    <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.side_title}}</h4>
                        <div class="card-body">
                            {% image page.side_image fill-200x200 class="img-fluid rounded-circle d-block mx-auto" alt="" %}
                        </div>
                        <div class="card-body">
                            <div class="rich-text">
                                {{ page.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">
            Say something at the end.
        </span>
    </nav>
{% endblock %}

{% block extra_js %}
    {{ block.super }}
{% endblock %}
```

wagtailcore_tagsとwagtailimages_tagsをloadすることで，Wagtailが用意しているテンプレートタグが使えるようになる（上の例では，imageタグやrichtextフィルタ）．また，ページインスタンスのフィールド情報には`page.フィールド名`でアクセスできていることがわかる．RichTextフィールドに格納された文書ではhtmlタグがエスケープされているので，richtextフィルタを通して元に戻している．また，その部分を`<div class="rich-text">`で囲むことによって，css/cms/base.cssに記述した下記の指定が効くようにしてある（詳しくは[ここ](https://docs.wagtail.io/en/stable/topics/writing_templates.html)を参照）．

```css
.rich-text img {
    max-width: 100%;
    height: auto;
}
```

ここでは，imageタグを用いた画像の扱い方には立ち入らない（が，詳しくは[ここ](https://docs.wagtail.io/en/stable/topics/images.html)を参照してほしい）．nav_barブロックとfooterブロックはダミーである．headerブロックには，cover_imageで指定した画像を背景にしたジャンボトロンを表示させるようにした．サイドバーにはBootstrapのcardを利用している．

# おわりに

今回は，Wagtailでのページの定義，編集，表示までの流れをざっと紹介した．素のDjangoで同様のページを作成するよりはかなり簡単だと思う．また，個人的には，管理サイトの使い勝手も気に入っている．見栄えの調整は少し面倒かもしれないが，公開されているBootstrapのテンプレートを利用する手もある（その方法は[このチュートリアル](https://www.accordbox.com/blog/wagtail-tutorials-6-import-theme-wagtail-blog/)が詳しい）．

次回以降は，少しずつ細かな話題に触れていきたいと思う．

# リンク

- [Wagtailのすすめ(1) DjangoベースのCMSを比較・検討してWagtailを選択した話](https://qiita.com/j54854/items/96396f24fe8ce9b60d05)
- [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)
- [Wagtailのすすめ(6) カテゴリとタグを追加しよう](https://qiita.com/j54854/items/111fb99eadfac6a26c29)