Add Blog Models to Wagtail

Michael Yin

Last updated on May 21 2021

Table of Contents

Wagtail Tutorial Series:

  1. Create Wagtail Project
  2. Dockerizing Wagtail App
  3. Add Blog Models to Wagtail
  4. How to write Wagtail page template
  5. Add Bootstrap Theme to Wagtail
  6. How to use StreamField in Wagtail
  7. Wagtail Routable Page
  8. Add pagination component to Wagtail
  9. Customize Wagtail Page URL

The source code is available on https://github.com/AccordBox/wagtail-bootstrap-blog

Objectives

By the end of this chapter, you should be able to:

  1. Create Django app.
  2. Add blog models and understand how it works.
  3. Learn how to run code and check data in the Django shell.

Page structure

Let's look at the page structures before we start, which can help us better understand the next sections.

There would be two page types in our project, BlogPage and PostPage

BlogPage would be the index page of the PostPage

So the page structures would seem like this.

BlogPage
    PostPage1
    PostPage2
    PostPage3
    PostPage4

Create Blog App

Let's create a Django app blog

$ docker-compose run --rm web python manage.py startapp blog

Here we run python manage.py startapp blog in a temp docker container, --rm option means the container would be deleted when exited.

Now we can see Django app blog created at the root directory.

.
├── Dockerfile
├── blog
├── compose
├── docker-compose.yml
├── home
├── manage.py
├── requirements.txt
├── search
└── wagtail_bootstrap_blog

Add blog to the INSTALLED_APPS of wagtail_bootstrap_blog/settings/base.py

INSTALLED_APPS = [
    'home',
    'search',
    'blog',
    # code omitted for brevity
]

Next, let's start adding blog models, there are mainly two types of models we need to add here.

  1. Page models (BlogPage, PostPage)
  2. Other models (Category, Tag)

Page Models

blog/models.py

from django.db import models
from wagtail.core.models import Page
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.admin.edit_handlers import FieldPanel


class BlogPage(Page):
    description = models.CharField(max_length=255, blank=True,)

    content_panels = Page.content_panels + [FieldPanel("description", classname="full")]


class PostPage(Page):
    header_image = models.ForeignKey(
        "wagtailimages.Image",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
    )

    content_panels = Page.content_panels + [
        ImageChooserPanel("header_image"),
    ]

Notes:

  1. When we create page models, please make sure all page classes inherit from the Wagtail Page class.
  2. Here we add a description field to the BlogPage and a header_image field to the PostPage.
  3. We should also add edit handlers to the content_panels so we can edit the fields in Wagtail admin.

Category and Tag

To make the blog supports Category and Tag features, let's add some models.

blog/models.py

from django.db import models
from wagtail.snippets.models import register_snippet
from taggit.models import Tag as TaggitTag


@register_snippet
class BlogCategory(models.Model):
    name = models.CharField(max_length=255)
    slug = models.SlugField(unique=True, max_length=80)

    panels = [
        FieldPanel("name"),
        FieldPanel("slug"),
    ]

    def __str__(self):
        return self.name

    class Meta:
        verbose_name = "Category"
        verbose_name_plural = "Categories"


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

Notes:

  1. Here we created two models, both of them inherit from the standard Django models.Model
  2. register_snippet decorator will register the models as Wagtail snippets, that can make us add/edit/delete the model instances in snippets of Wagtail admin.
  3. Since Wagtail already has tag support built on django-taggit, so here we create a proxy-model to declare it as wagtail snippet

Intermediary model

Now page models and snippet models are already defined. But we still need to create Intermediary models so the connections between them can be stored in the db.

from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase

class PostPageBlogCategory(models.Model):
    page = ParentalKey(
        "blog.PostPage", on_delete=models.CASCADE, related_name="categories"
    )
    blog_category = models.ForeignKey(
        "blog.BlogCategory", on_delete=models.CASCADE, related_name="post_pages"
    )

    panels = [
        SnippetChooserPanel("blog_category"),
    ]

    class Meta:
        unique_together = ("page", "blog_category")


class PostPageTag(TaggedItemBase):
    content_object = ParentalKey("PostPage", related_name="post_tags")

Notes:

  1. PostPageBlogCategory is to store the connection between PostPage and Category
  2. Please remember to use ParentalKey instead of models.ForeignKey, I will talk about it in a bit.
  3. unique_together = ("page", "blog_category") would add db constraints to avoid duplicate records. You can check Django unique_together to learn more.
  4. Some online resources teach people to use ParentalManyToManyField, I do not recommend use ParentalManyToManyField in Wagtail app even it seems more easy to understand. You can check this Wagtail tip for more details.

Next, let's update the PostPage model so we can add/edit/remove Category and Tag for the page in Wagtail admin.

from modelcluster.tags import ClusterTaggableManager


class PostPage(Page):
    header_image = models.ForeignKey(
        "wagtailimages.Image",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
    )

    tags = ClusterTaggableManager(through="blog.PostPageTag", blank=True)

    content_panels = Page.content_panels + [
        ImageChooserPanel("header_image"),
        InlinePanel("categories", label="category"),
        FieldPanel("tags"),
    ]

Notes:

  1. For tag support, We add ClusterTaggableManager and use through to specify the intermediary model we just created.
  2. For category support, add InlinePanel("categories", label="category") to the content_panels. The categories relationship is already defined in PostPageBlogCategory.page.related_name
  3. The PostPageBlogCategory.panels defines the behavior in InlinePanel, which means we can set multiple blog_category when we create or edit page.

Source Code

Below is the full code of the blog/models.py for reference

from django.db import models
from modelcluster.fields import ParentalKey
from modelcluster.tags import ClusterTaggableManager
from taggit.models import Tag as TaggitTag
from taggit.models import TaggedItemBase
from wagtail.admin.edit_handlers import (
    FieldPanel,
    FieldRowPanel,
    InlinePanel,
    MultiFieldPanel,
    PageChooserPanel,
    StreamFieldPanel,
)
from wagtail.core.models import Page
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.snippets.edit_handlers import SnippetChooserPanel
from wagtail.snippets.models import register_snippet


class BlogPage(Page):
    description = models.CharField(max_length=255, blank=True,)

    content_panels = Page.content_panels + [FieldPanel("description", classname="full")]


class PostPage(Page):
    header_image = models.ForeignKey(
        "wagtailimages.Image",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
    )

    tags = ClusterTaggableManager(through="blog.PostPageTag", blank=True)

    content_panels = Page.content_panels + [
        ImageChooserPanel("header_image"),
        InlinePanel("categories", label="category"),
        FieldPanel("tags"),
    ]


class PostPageBlogCategory(models.Model):
    page = ParentalKey(
        "blog.PostPage", on_delete=models.CASCADE, related_name="categories"
    )
    blog_category = models.ForeignKey(
        "blog.BlogCategory", on_delete=models.CASCADE, related_name="post_pages"
    )

    panels = [
        SnippetChooserPanel("blog_category"),
    ]

    class Meta:
        unique_together = ("page", "blog_category")


@register_snippet
class BlogCategory(models.Model):
    name = models.CharField(max_length=255)
    slug = models.SlugField(unique=True, max_length=80)

    panels = [
        FieldPanel("name"),
        FieldPanel("slug"),
    ]

    def __str__(self):
        return self.name

    class Meta:
        verbose_name = "Category"
        verbose_name_plural = "Categories"


class PostPageTag(TaggedItemBase):
    content_object = ParentalKey("PostPage", related_name="post_tags")


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

Migrate DB

After we finish the models part, let's migrate our db so db tables would be created or migrated.

$ docker-compose run --rm web python manage.py makemigrations
$ docker-compose run --rm web python manage.py migrate

Setup The Site

# create superuser and password
$ docker-compose run --rm web python manage.py createsuperuser

$ docker-compose up -d

# tail the log
$ docker-compose logs -f

Notes:

  1. Login on http://127.0.0.1:8000/admin/
  2. Go to http://127.0.0.1:8000/admin/pages/ to create BlogPage beside the HomePage.
  3. Follow settings/site in the sidebar to change the root page of the localhost site to the BlogPage we just created.
  4. Go to http://127.0.0.1:8000/admin/pages/ delete the HomePage
  5. Now if we visit http://127.0.0.1:8000/ we will see TemplateDoesNotExist exception. This is correct and we will fix it later, do not worry.

Add PostPage

  1. Follow Pages/BlogPage in the sidebar (not the edit icon)
  2. Now the URl would seem like http://127.0.0.1:8000/admin/pages/4/
  3. Click the Add child page button to start adding PostPage as children of the BlogPage
  4. You can create Category and Tag when you create the PostPage
  5. Remember to publish the page after you edit the page.

Simple Test

Even we do not write code for the templates yet, we can still use Python code to quickly test code or data in the Django shell.

# please run code in new Django shell if you change something
$ docker-compose run --rm web python manage.py shell
>>> from blog.models import PostPage

>>> page = PostPage.objects.first()
>>> page.title
'PostPage1'
>>> page.tags.all()
[<Tag: Django>]
>>> page.categories.all()
<QuerySet [<PostPageBlogCategory: PostPageBlogCategory object (1)>]>
>>> page.categories.first().blog_category
<BlogCategory: Programming>
>>> exit()

ParentalKey

Many people have not much experience on Django when they learn Wagtail. So here I'd like to talk about a little more about the ParentalKey and the difference between with ForeignKey

Let's assume you are building a CMS framework which support preview feature, and now you have a live post page which has category category 1

So in the table, the data would seem like this.

PostPage: postpage 1 (pk=1)

Category: category 1 (pk=1)

PostPageCategory (pk=1, blog_category=1, page=1)

Some editor wants to change the page category to category 2, and he even wants to preview it before publishing it. So what is your solution?

  1. You need to create something like PostPageCategory (blog_category=2, page=1) in memory and not write it to PostPageCategory table. (Because if you do, it will affect the live page)
  2. You need to write code to convert the page data, and the PostPageCategory to some serialize format (JSON for example), and save it to some revision table as the latest revision.
  3. On the preview page, fetch the data from the revision table and deserialize to a normal page object, and then render it to HTML.

Unfortunately, Django's ForeignKey can not work in this case, because it needs PostPageCategory (blog_category=2, page=1) to save to db first, so it has pk

That is why django-modelcluster is created and ParentalKey is introduced.

Now We can solve the above problem in this way.

  1. Make the PostPage inherit from modelcluster.models.ClusterableModel. Actually, Wagtail Page class already did this
  2. And define the PostPageCategory.page as ParentalKey field.
  3. So the Wagtail page (ClusterableModel) can hold the PostPageCategory in memory even it is not created in db yet. (has null pk)
  4. We can then serialize the page to JSON format (also contains PostPageCategory info) and save to revision table.
  5. Now editor can preview the page before publishing it.

If you want to dive deeper, try to use code below to check on your local:

>>> from wagtail.core.models import PageRevision
# page__pk is the primary key of the page
>>> revision = PageRevision.objects.filter(page__pk=page__pk).first()
>>> revision.content_json

So below are tips:

  1. If you define some ForeignKey relationship with Page in Page class, for example PostPage.header_image, use ForeignKey. (This has no the above problem)
  2. If you define some ForeignKey relationship with Page in other class, for example, PostPageBlogCategory.page, use ParentalKey.

Wagtail Tutorial Series:

  1. Create Wagtail Project
  2. Dockerizing Wagtail App
  3. Add Blog Models to Wagtail
  4. How to write Wagtail page template
  5. Add Bootstrap Theme to Wagtail
  6. How to use StreamField in Wagtail
  7. Wagtail Routable Page
  8. Add pagination component to Wagtail
  9. Customize Wagtail Page URL

The source code is available on https://github.com/AccordBox/wagtail-bootstrap-blog


Michael Yin

Michael is a Full Stack Developer from China who loves writing code, tutorials about Django, Wagtail CMS and React.

He has published some ebooks on leanpub and tech course on testdriven.io.

He is also the founder of the AccordBox which provides the web development services.


Table of Contents

Search

This book will teach you how to build a SPA (single-page application) with React and Wagtail CMS

Read More

Subscribe

Get notified about new great Web Development Tutorial