はじめに
Wagtailの管理サイトでは,既存のページにADD CHILD PAGEすることで新しいページインスタンスを作成する.例えば,前回は,デフォルトで用意されてるHomePageクラスのインスタンス("Welcome to your new Wagtail site!"ページ)の子要素として,TopPageクラスのインスタンスを1つ生成したことを思い出そう.
この結果,(デフォルトのHomePageインスタンス以外の)すべてのページインスタンスはそれぞれ,ただ1つの親要素をもつことになる.また,各ページインスタンスは複数の子要素をもつことができる.このように,Wagtailの中のページインスタンスはすべてツリー構造で規定される親子関係で互いに結ばれていると言える.
各ページのURLもこのツリー構造に従って自動的に付与される(ので,urls.pyでのルーティングの指定は不要になる).例えば,デフォルトのHomePageインスタンスのアドレスが,初期設定の
のままであるとすると,その子要素として作成されたあるTopPageインスタンスのアドレスは,
となる(helloはこのページのSlugに指定されている文字列).さらに,その子要素として生成された別のページのSlugの文字列がworldだったとすると,そのページのアドレスは,
となるわけである.
今回は,まずPlainPageクラスを追加した後,管理サイトでページインスタンスを増やし,このツリー構造を実感してみるとともに,それを利用したちょっとしたテクニックを試してみよう.
ページクラスの追加
最初に,前回のTopPageに加えて,PlainPageの定義を追加する.具体的には,cms/models.pyを下のように拡張した.
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つである.他にも類似のメソッドが用意されているので,ここで確認してみてほしい.
これでPlainPageクラスが定義できたので,管理サイトにログインして,前回作成したTopPageインスタンスの子要素としてPlainPageインスタンスをいくつか生成しておこう(それらを後で利用する).
ナビゲーションアイテムの保持
新しいページクラスを定義し,そのインスタンスをいくつか作成したので,次にそれらをブラウザに表示させてみたくなるが,その前に,TopPageクラスの方にも少し拡張を加えておこう.前回ダミーのまま放置しておいたnav_barブロックとfooterブロックをそれぞれ実装するための準備である.具体的には,下記のようにコードを拡張した.
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を下のように定義した.
{% 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コードをコンパイル・圧縮したものである.
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だ.
{% extends 'cms/plain_page.html' %}
すなわち,TopPageクラスのページにもPlainPageクラスのためのテンプレートをそのまま利用することができる.そう,TopPageクラスに一見無駄なように見えるget_top_page()メソッドを追加したのは,このためだったというわけだ.
おわりに
今回は,Wagtailの管理下にあるページがツリー構造にまとめられていることを理解した上で,それを利用するテクニックの一端に触れてみた.また,Orderableクラスを利用してページの部品を定義することも試した.
次回は,ListPageクラスを追加するとともに,それに関連してまたちょっとしたテクニックにも触れてみたい.