Base 6 — Wagtail CMS for the content
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.
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 requirementsIt 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 = TrueDocumentation 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 migratesWe 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 devNavigating to http://127.0.0.1:8001/admin will give us a log-in screen.
A few conclusions from this log-in page:
- Wagtail is working. It uses the right images.
- Wagtail is not using
django-allauth. piotris 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.
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
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 djpoeThe 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
Pagemodel, 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... OKEdit 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/
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
Our HomePage isn’t fully configured yet. When we try to load it, we see the error.
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.htmlThere 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.
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)),
]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.
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 = trueUpdate 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 migrateIgnore 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-foreignThis 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 homepageDeployment
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 homepageHost 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 s3Click on Create bucket button. Choose the unique name, switch to “ACLs enabled,” and disable the “block all public access” setting.
The bucket will be listed, and you can browse its contents, but for now, it’s empty.
Add an AWS user and create a group for the privileges.
djpoe ➜ assume -c zalun -s iamCreate the user and the group, and add the user to the group.
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.
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.
In the next step, provide a description of the access key.
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 requirementsAnd 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/ubsXXXAfter 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
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
- Base 5–3rd party authentication in Django
- part-5-auth branch
- Wagtail documentation
- Add Wagtail to existing Django project
- Wagtail documentation — Writing templates
- DigitalOcean — Spaces Object Storage
- django-storages on Space Object Storage
- django-storages on Amazon S3
- Granted — The easiest way to access your cloud
- DJPoe repository
