Sitemap

Base 6 — Wagtail CMS for the content

15 min readSep 29, 2024

In this sixth article of our Django boilerplate series, we’re integrating Wagtail CMS to manage our project’s content. While Django’s admin panel is powerful, it often falls short of complex content management needs. Wagtail offers a user-friendly interface that content editors will appreciate, with powerful features like flexible page modeling and excellent image management. Its SEO tools and content workflow capabilities make it an ideal choice for projects that require robust content management beyond what the default Django admin can provide. Adding Wagtail to our boilerplate enhances our project’s functionality and provides a solid foundation for content-driven websites.

Press enter or click to view image in full size
Microsoft designer hallucinating the article’s title

In the last article, Base 5–3rd party authentication in Django, we’ve added social sign-in to our boilerplate. The code is on GitHub in the part-5-auth branch. We will start from here.

Installation and basic configuration

By default, Wagtail is installed as a project, like Django. We will take the other approach and add it as any other Django application.

djpoe ➜ poetry add wagtail
djpoe ➜ poe requirements

It adds 34 dependency installs.

The documentation explains how to connect Wagtail to the existing Django project with existing project.

# djpoe/djpoe/settings.py
# ...
# Application definition
INSTALLED_APPS = [
"helloworld",
# Wagtail
"wagtail.contrib.forms",
"wagtail.contrib.redirects",
"wagtail.embeds",
"wagtail.sites",
"wagtail.users",
"wagtail.snippets",
"wagtail.documents",
"wagtail.images",
"wagtail.search",
"wagtail.admin",
"wagtail",
"taggit",
"modelcluster",
# ...
]

MIDDLEWARE = [
# ...
# Wagtail
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
]
# ...

STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = (BASE_DIR / "static",)
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"

# ...

# WAGTAIL SETTINGS

# This is the human-readable name of your Wagtail install
# which welcomes users upon login to the Wagtail admin.
WAGTAIL_SITE_NAME = "DJPoe"

# Replace the search backend
# WAGTAILSEARCH_BACKENDS = {
# 'default': {
# 'BACKEND': 'wagtail.search.backends.elasticsearch8',
# 'INDEX': 'myapp'
# }
# }

# Wagtail email notifications from address
# WAGTAILADMIN_NOTIFICATION_FROM_EMAIL = 'wagtail@myhost.io'

# Wagtail email notification format
# WAGTAILADMIN_NOTIFICATION_USE_HTML = True

# Allowed file extensions for documents in the document library.
# This can be omitted to allow all files, but note that this may present a security risk
# if untrusted users are allowed to upload files -
# see https://docs.wagtail.org/en/stable/advanced_topics/deploying.html#user-uploaded-files
WAGTAILDOCS_EXTENSIONS = ["csv", "docx", "key", "odt", "pdf", "pptx", "rtf", "txt", "xlsx", "zip"]

# Reverse the default case-sensitive handling of tags
TAGGIT_CASE_INSENSITIVE = True

Documentation suggested moving the "yourapp" to the top and adding the wagtail... apps before django... ones. We also are adding the taggit, and modelcluster apps that Wagtail uses internally.

Additionally, I copied the suggested Wagtail settings, which I will customize later in the process.

The migration has to happen now.

djpoe ➜ poe migrate
Poe => python ./djpoe/manage.py migrate

Operations to perform:
Apply all migrations: account, admin, auth, contenttypes, sessions, socialaccount, taggit, wagtailadmin, wagtailcore, wagtaildocs, wagtailembeds, wagtailforms, wagtailimages, wagtailredirects, wagtailsearch, wagtailusers
Running migrations:
... a very long list of migrates

We need to add the app’s URLs as well.

# djpoe/djpoe/urls.py

# ...

from django.contrib import admin
from django.urls import include, path, re_path
from helloworld.views import index
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls

urlpatterns = [
# modified admin path to avoid conflict with wagtail
path("django-admin/", admin.site.urls),
# wagtail admin
path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)),
# django-allauth
path("accounts/", include("allauth.urls")),
# helloworld
path("", index, name="homepage"),
# For anything not caught by a more specific rule above, hand over to
# Wagtail's serving mechanism
re_path(r"", include(wagtail_urls)),
]

We modified the Django admin app’s URL to django-admin to avoid conflict with Wagtail’s admin URL. We could change the setting for Wagtail instead, but I think it’s better to stay with the default. At least for now.

djpoe ➜ poe dev

Navigating to http://127.0.0.1:8001/admin will give us a log-in screen.

Press enter or click to view image in full size

A few conclusions from this log-in page:

  1. Wagtail is working. It uses the right images.
  2. Wagtail is not using django-allauth.
  3. piotr is recognized as an authenticated user without the correct permissions

Let’s add the superuser privileges to the user piotr. We can’t just call the poe manage createsuperuser, as the user already exists. We need to do it directly in the database.

djpoe ➜ djpoe poe manage dbshell
Poe => python ./djpoe/manage.py dbshell
sqlite> update auth_user set is_stuff=1, is_superuser=1 where username="piotr";

After reloading the page, we see Wagtail's dashboard page. It correctly recognized the avatars.

Press enter or click to view image in full size

Let’s see the page created by Wagtail. We can comment out the homepage app’s urls.py section

    # helloworld
# path("", index, name="homepage"),

Now we can navigate to the homepage at http://127.0.0.1:8001

Press enter or click to view image in full size
Wagtail’s default homepage

Wagtail is working. I’ll keep it clean for now. We’ve got the accounts pages ready at /accounts/login and /accounts/logout. I want to create a minimal and functional homepage and make the settings more flexible by configuring them with environment variables.

Homepage

Creating the project using the wagtail start template creates a home app that can change the look of the HomePage. We must do it ourselves since we added the Wagtail to an existing Django project.

Add the HomePage app

djpoe ➜ poe manage startapp home
djpoe ➜ rm home/admin.py home/apps.py home/tests.py home/views.py
djpoe ➜ mv home djpoe

The home app is created with Django command in main directory. We need to move it to django/, the project directory.

"home" has to be placed in the INSTALLED_APPS list.

# djpoe/djpoe/settings.py
# ...
# Application definition
INSTALLED_APPS = [
"home",
# Wagtail
"wagtail.contrib.forms",
# ...

Then, we will create a new Page and manually replace the HomePage one.

The initial “Welcome to your new Wagtail site!” page is a placeholder using the base Page model, and is not directly usable. After defining your own home page model, you should create a new page at the root level through the Wagtail admin interface, and set this as the site’s homepage (under Settings / Sites). You can then delete the placeholder page.

Later on, we will use Django fixtures to avoid this manual labor on each new database…

Let’s copy the HomePage from the official tutorial. We will add it with some changes suggested by ruff.

# djpoe/home/models.py
"""Models for the home app."""

from typing import ClassVar

from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField
from wagtail.models import Page


class HomePage(Page):
"""Home page model."""
body = RichTextField(blank=True)
content_panels: ClassVar[list[FieldPanel]] = [*Page.content_panels, FieldPanel("body")]

It adds the body element edited with a rich text editor.

We need to migrate the database to add the new model.

djpoe ➜ poe makemigrations
Poe => python ./djpoe/manage.py makemigrations
System check identified some issues:

Migrations for 'home':
djpoe/home/migrations/0001_initial.py
+ Create model HomePage

djpoe ➜ poe migrate
Poe => python ./djpoe/manage.py migrate
System check identified some issues:

Operations to perform:
Apply all migrations: account, admin, auth, contenttypes, home, sessions, socialaccount, taggit, wagtailadmin, wagtailcore, wagtaildocs, wagtailembeds, wagtailforms, wagtailimages, wagtailredirects, wagtailsearch, wagtailusers
Running migrations:
Applying home.0001_initial... OK

Edit the content

We can now navigate to Pages / + and create a new homepage Page http://127.0.0.1:8001/admin/pages/add/home/homepage/1/

Press enter or click to view image in full size
Editor for the new HomePage

After Saving and then publishing the page, we see a warning:

The root level is where you can add new sites to your Wagtail installation. 
Pages created here will not be accessible at any URL until they are associated
with a site. Configure a site now.

If you just want to add pages to an existing site, create them as children of
the homepage instead.

The first warning is about configuring the site. Navigate to the provided link, see the pre-configured localhost site, and fix the port settings to 8001. Then, set the Root page to our new HomePage, and hit Save

Press enter or click to view image in full size

Our HomePage isn’t fully configured yet. When we try to load it, we see the error.

Press enter or click to view image in full size

Adding a template

The template file djpoe/home/templates/home_page.html is missing. To check if it’s the right path, I usually copy a blind HTML file. We will use the one from the homepage app.

djpoe ➜ mkdir -p djpoe/home/templates
djpoe ➜ cp djpoe/helloworld/templates/helloworld/index.html djpoe/home/templates/home/home_page.html

There is no error, but we don’t see the edited data. Wagtail provides documentation about editing templates. Let’s use it to create a simple page with auth menu.

{# djpoe/home/templates/home/home_page.html #}
{% load wagtailcore_tags %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page.title }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css" integrity="sha512-EZLkOqwILORob+p0BXZc+Vm3RgJBOe1Iq/0fiI7r/wJgzOFZMlsqTa29UEl6v6U6gsV4uIpsNZoV32YZqrCRCQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body>
{% if user.is_authenticated %}
<p>
Welcome, {{ user.username }}!
<a href="{% url 'account_logout' %}">Logout</a>
</p>
{% else %}
<p>
Welcome, guest!
<a href="{% url 'account_login' %}">Login</a>
<a href="{% url 'account_signup' %}">Sign Up</a>
</p>
{% endif %}
{{ page.body|richtext }}
</body>
</html>

First, we load the Wagtail tags. This will allow us to use the richtext tag on the page.body content. We’ve changed the title for page.title, and left the log-in “header”.

In the body section, we use the {% if user.is_authenticated %} tag to define the conditional block containing a personal greeting and a logout link displayed when the user is authenticated. We provide login/signup links for unauthenticated visitors

Finally, {{ page.body|richtext }} renders the content entered in the editor.

Press enter or click to view image in full size
HomePage with only `alt` displayed

The image is not loaded and shows only the alt parameter.
The HTML code for it shows it tries to load the image from /images/ directory.

<img 
alt="20230930-DSC_3192"
class="richtext-image full-width"
height="281"
src="/images/20230930-DSC_3192.width-800.jpg"
width="800"
>

Serve images

We need to tell Django to self-host the images. This isn’t right for the deployed project, so we must keep that in mind.

We will add the static URLs. These are already configured in settings.py

# djpoe/djpoe/urls.py

from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path, re_path
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls

urlpatterns = [
path("django-admin/", admin.site.urls),
path("admin/", include(wagtailadmin_urls)),
path("accounts/", include("allauth.urls")),
re_path(r"^media/(?P<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT}),
re_path(r"^static/(?P<path>.*)$", serve, {"document_root": settings.STATIC_ROOT}),
re_path(r"", include(wagtail_urls)),
]
Press enter or click to view image in full size
HomePage with a visible image
Press enter or click to view image in full size
Edit the HomePage mode with a visible image.

Cleaning up

Remove the helloworld app

We need to remove the helloworld app, we are no longer using it. It is mentioned in quite a few places. Please remove it from all the files mentioned in the search panel. Some files and the djpoe/homepage directory will be deleted entirely.

Press enter or click to view image in full size

Tests

Tests failed as we changed the URL to the Django admin pages.

E       AssertionError: assert '/django-admi...django-admin/' == '/admin/login/?next=/admin/'
E
E - /admin/login/?next=/admin/
E + /django-admin/login/?next=/django-admin/
E ? +++++++ +++++++

We need to add the django- prefix to the string. The asserts section will now look like this:

# tests/test_admin_page.py
# ...
assert response.status_code == 302
assert response.content == b""
assert response.url == "/django-admin/login/?next=/django-admin/" # type: ignore[attr-defined]

Fix typing

After running poe mypy I found that the wagtail app is untyped.

djpoe ➜ poe mypy      
Poe => mypy --install-types --check-untyped-defs .
djpoe/home/models.py: error: Skipping analyzing "wagtail.contrib.redirects.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
djpoe/home/models.py: error: Skipping analyzing "wagtail.models.sites": module is installed, but missing library stubs or py.typed marker [import-untyped]
djpoe/home/models.py: error: Skipping analyzing "wagtail.models.i18n": module is installed, but missing library stubs or py.typed marker [import-untyped]
djpoe/home/models.py: error: Skipping analyzing "wagtail.contrib.forms.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
djpoe/home/models.py: error: Skipping analyzing "wagtail.search.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
djpoe/home/models.py:3: error: Skipping analyzing "wagtail.admin.panels": module is installed, but missing library stubs or py.typed marker [import-untyped]
djpoe/home/models.py:4: error: Skipping analyzing "wagtail.fields": module is installed, but missing library stubs or py.typed marker [import-untyped]
djpoe/home/models.py:5: error: Skipping analyzing "wagtail.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
djpoe/djpoe/urls.py:23: error: Skipping analyzing "wagtail": module is installed, but missing library stubs or py.typed marker [import-untyped]
djpoe/djpoe/urls.py:24: error: Skipping analyzing "wagtail.admin": module is installed, but missing library stubs or py.typed marker [import-untyped]
djpoe/djpoe/urls.py:24: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
djpoe/djpoe/urls.py:25: error: Skipping analyzing "wagtail.documents": module is installed, but missing library stubs or py.typed marker [import-untyped]
Found 11 errors in 2 files (checked 18 source files)

Unfortunately, neither Django nor Wagtail is typed. To make mypy happy, we need to switch off analyzing for all of these modules. We can’t do it as we did with django-environ — by adding a # type: ignore[import-untyped] as we do import it in quite a few places. The solution has to work for the entire project.

# pyproject.toml

[[tool.mypy.overrides]]
module = "wagtail.*"
ignore_missing_imports = true

Update packages

During the work on this article, Wagtail (along with other packages) released a new version.

djpoe ➜ poe find_updates
Poe => poetry show --outdated | grep --file=<(poetry show --tree | grep '^\w' | sed 's/^\([^ ]*\).*/^\1/')
django-allauth 64.2.1 65.0.1 Integrated set of Django ap...
django-stubs 5.0.4 5.1.0 Mypy stubs for Django
django-stubs-ext 5.0.4 5.1.0 Monkey-patching and extensi...
django-taggit 5.0.1 6.0.0 django-taggit is a reusable...
pylint 3.3.0 3.3.1 python code static checker
ruff 0.6.6 0.6.7 An extremely fast Python li...
wagtail 6.2.1 6.2.2 A Django content management...
djpoe ➜ poetry add "django-allauth=^65" "wagtail=^6.2"
djpoe ➜ poetry add --group dev "django-stubs-ext=^5"
djpoe ➜ poe requirements
djpoe ➜ poe migrate

Ignore uploaded media

# .gitignore
# ...


# Wagtail image content
images/
original_images/

Some flexible configuration

We’ve got a default setting for the Wagtail site name. It is displayed in the admin page.

WAGTAIL_SITE_NAME = "DJPoe"

We also get a warning each time we run manage script:

?: (wagtailadmin.W003) The WAGTAILADMIN_BASE_URL setting is not defined
HINT: This should be the base URL used to access the Wagtail admin site. Without this, URLs in notification emails will not display correctly.

Let’s make them configurable with environment variables:

# djpoe/djpoe/settings.py
# ...
env = environ.FileAwareEnv(
# ...
WAGTAIL_SITE_NAME=(str, "DJPoe"),
WAGTAILADMIN_BASE_URL=(str, "http://localhost:8001"),
WAGTAILADMIN_NOTIFICATION_USE_HTML=(bool, True),
DEFAULT_FROM_EMAIL=(str, "email@example.com"),
)

# ...
WAGTAIL_SITE_NAME = env("WAGTAIL_SITE_NAME")
WAGTAILADMIN_BASE_URL = env("WAGTAILADMIN_BASE_URL")
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL")
WAGTAILADMIN_NOTIFICATION_USE_HTML = env("WAGTAILADMIN_NOTIFICATION_USE_HTML")

I wanted to change the Wagtail login page to the allauth one (by setting the WAGTAILADMIN_LOGIN_URLto “/admins/login”), but if a user has no right to edit pages, it ends up with an infinite redirect loop. There’s a way to change the login template.

Testing HomePage

Let’s dump the data into a fixture.

djpoe ➜ mkdir djpoe/home/fixtures
djpoe ➜ poe manage dumpdata \
wagtailcore.locale wagtailcore.site wagtailcore.page home \
-o djpoe/home/fixtures/homepage.json \
--format json \
--indent 2 \
--natural-foreign

This will create a djpoe/home/fixtures/homepage.json fixture file. It is long as it contains pages pre-built by Wagtail. I will edit it and remove unnecessary parts, but first, we need to be able to load and test it.

The test file:

# tests/test_home.py

import pytest
from django.core.management import call_command
from django.test import Client


@pytest.mark.django_db
def test_homepage(client: Client):
call_command("loaddata", "homepage.json")

response = client.get("/")
assert "DJPoe - boilerplate for side projects" in response.content.decode()

The test would fail to load the homepage.json fixture on several occasions. I needed to remove from it the owner of the page, revision_id etc., because the related entries were absent in the plain test database. After the fixture started to load correctly, the test passed. We need to remove the parts that seem less important or unnecessary. First, I left only the home page. That means leaving a page with “content_type”: [“home”, “homepage”]and removing the ones with “content_type”: [“wagtailcore”, “page”]. Then, I left only the required fields in the home page entry. As I wanted the homepage to work well in deployment, I’ve also changed the primary keys to replace the one pre-built by Wagtail.

The final test fixture is provided below:

[
{
"model": "wagtailcore.locale",
"pk": 1,
"fields": {
"language_code": "en"
}
},
{
"model": "wagtailcore.site",
"pk": 1,
"fields": {
"hostname": "localhost",
"port": 8001,
"site_name": "",
"root_page": 2,
"is_default_site": true
}
},
{
"model": "wagtailcore.page",
"pk": 2,
"fields": {
"path": "00010002",
"depth": 2,
"numchild": 0,
"locale": 1,
"live": true,
"title": "DJPoe HomePage",
"draft_title": "DJPoe HomePage",
"slug": "the-new-homepage",
"content_type": [
"home",
"homepage"
],
"url_path": "/the-new-homepage/",
"seo_title": "",
"show_in_menus": false
}
},
{
"model": "home.homepage",
"pk": 2,
"fields": {
"page_ptr": 2,
"body": "<h2 data-block-key=\"bdss1\">DJPoe - boilerplate for side projects</h2><p data-block-key=\"eases\"><a href=\"https://github.com/zalun/djpoe\">https://github.com/zalun/djpoe</a></p>"
}
}
]

If one wants to work on multiple dev machines (This article is written on desktop and laptop), the fixture can be loaded with:

djpoe ➜ poe manage loaddata homepage

Deployment

After DigitalOcean successfully built the app, click on the Console panel and migrate the database. Then, load the homepage fixture.

$ python ./djpoe/manage.py migrate
$ python ./djpoe/manage.py loaddata homepage

Host media on another server

The deployment isn’t finished yet. The media/ directory is deleted when the site is deployed. We need to find a way to store the files on another server. django-storages to the rescue!

I checked the DigitalOcean offer on Spaces Object Storage. It’s $5 for 250G storage and 1T bandwidth. This is a good offer. I will consider it for commercial deployment. The configuration is almost identical to the AWS S3 storage one. The latter is better for a site like DJPoe due to the free plan for low usage.

First, we need to create a S3 bucket.

Navigate to AWS S3. I use granted. This command logs me into AWS and opens the Amazon S3 page.

djpoe ➜ assume -c zalun -s s3

Click on Create bucket button. Choose the unique name, switch to “ACLs enabled,” and disable the “block all public access” setting.

Press enter or click to view image in full size

The bucket will be listed, and you can browse its contents, but for now, it’s empty.

Press enter or click to view image in full size

Add an AWS user and create a group for the privileges.

djpoe ➜ assume -c zalun -s iam

Create the user and the group, and add the user to the group.

Press enter or click to view image in full size

Navigate to the Permissions tab and add the group full access to the S3 bucket. You may be more granular about this and access to the specific bucket.

Press enter or click to view image in full size

Go to Users page, go to the Security Credentials tab and, in the Access keys panel click on Create access key button. Choose Local code to generate access keys for local development.

Press enter or click to view image in full size

In the next step, provide a description of the access key.

Press enter or click to view image in full size

An access key is created. Store both the Access key and the Secret access key in djpoe/djpoe/.env file. It’s mentioned in .gitignore file, and safe as any other data on your local disk. Do not add this to your code or any file that could be committed.

Django configuration

We need to install the django-storages package with S3 extras.

djpoe ➜ poetry add "django-storages[s3]"
djpoe ➜ poe requirements

And configure the app in djpoe/djpoe/settings.py

# djpoe/djpoe/seettings.py
# ...
env = environ.FileAwareEnv(
# ...
AWS_BUCKET_NAME=(str, ""),
AWS_REGION_NAME=(str, ""),
AWS_ACCESS_KEY=(str, ""),
AWS_SECRET_KEY=(str, ""),
)
# ...
INSTALLED_APPS = [
# ...
"storages",
]
# ...
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": env("AWS_BUCKET_NAME"),
"region_name": env("AWS_REGION_NAME"),
"access_key": env("AWS_ACCESS_KEY"),
"secret_key": env("AWS_SECRET_KEY"),
"default_acl": "public-read",
},
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}

Edit the djpoe/djpoe/.env with the new entries.


AWS_BUCKET_NAME=djpoe-media
AWS_REGION_NAME=eu-central-1
AWS_ACCESS_KEY=AKIAVVADXXXX
AWS_SECRET_KEY=wQXXX/ubsXXX

After adding an image to the home page, we will see it uses the new URL https://djpoe-media.s3.eu-central-1.amazonaws.com/images/20230930-DSC_3192.max-800x600.jpg

The bucket now contains two directories — images and original_images

Press enter or click to view image in full size

Before deployment, create a new access key and add the variables to the DigitalOcean App environment.

Wrapping up

Wagtail is enormous, and we can do a lot with it. However, most of the changes I can think about will be relevant to the actual project implementation. I’m considering page layout, supporting multiple languages, or front-end frameworks. For now, this article ends the DJPoe saga. I might extend it in the future with some valuable features. I’m considering subscription payment integration, OpenTofu for infrastructure as code, and centralized logs.

Feel free to copy the code and use it in your project.
Leave a comment if you do.

The code for this article is placed in the part-6-cms branch.

Links used in the article

--

--

Piotr Zalewa
Piotr Zalewa

Written by Piotr Zalewa

Creator of JSFiddle, ex-Mozilla dev. Software consultant & mentor. I code and write about programming, mostly Python. Open to diverse technologies.

No responses yet