Adding editable forms including reCaptcha to your Wagtail site

Forms are a vital part of many sites. In this tutorial we will create a form builder with many different fields, with which you can create forms on any Wagtail page.

July 13, 2020, 10:10 a.m.
Themes: Wagtail Streamfield options

There are several ways to add forms to your Wagtail site. Wagtail has its own built-in form builder, which is great for on-the-fly forms in many situations. However in a multilingual site with wagtailtrans (as we have set up earlier), all pages are subclassed from TranslatablePage, whilst the Wagtail form page is not subclassed from the Page model but from AbstractForm. This makes it impossible or at least not trivial to use Wagtail's built-in form builder for multilingual sites. Hard-coding forms into page templates, as we have done earlier with a comment form, is another possibility, but not very flexible. Linking to urls with forms completely outside of Wagtail might be a suitable solution for sign in / sign up, but is also not ideal. In this tutorial we will implement Wagtail Streamforms, which will give us many out-of-the-box fields, such as singleline and multiline text, date, email, checkbox, file. We will also add a reCaptcha field to this and create a contact page with a contact form for our site. There is however a caveat I should mention at the beginning: a minor compatibility issue with Django 3.0, which I'll discuss near the end of this article.

Install with:

pip3 install wagtailstreamforms

Add it to requirements.txt and add the following to INSTALLED_APPS (wagtail.contrib.modeladmin might already be there):

'wagtail.contrib.modeladmin',
'wagtailstreamforms',

We can now create a model for a contact page in models.py (subclassed from TranslatablePage if our site is multilingual):

from wagtail.admin.edit_handlers import FieldPanel, StreamFieldPanel
from wagtail.core.fields import RichTextField, StreamField
from wagtail.core import blocks
from wagtailtrans.models import TranslatablePage
from wagtailstreamforms.blocks import WagtailFormBlock

class ContactPage(TranslatablePage):
    intro = RichTextField(blank=True)
    body = StreamField([
        ('paragraph', blocks.RichTextBlock()),
        ('form', WagtailFormBlock()),
    ])
    content_panels = TranslatablePage.content_panels + [
        FieldPanel('intro'),
        StreamFieldPanel('body'),
    ]

Of course we can add more fields to the page if we want to. Now we can create the template, in a file custom_form.html:

<form{% if form.is_multipart %} enctype="multipart/form-data"{% endif %} action="{{ value.form_action }}" method="post" class="needs-validation" novalidate>
    {{ form.media }}
    {% csrf_token %}
    {% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
    {% for field in form.visible_fields %}
        <div class="form-group">
            {% include "account/form_field.html" %}
        </div>
    {% endfor %}
    <button type="submit" class="btn btn-outline-primary">{{ value.form.submit_button_text }}</button>
</form>

It is very much like the template in the docs, we have just included our own nicely styled form_field.html template that we have used in earlier tutorials, and a different button. As instructed we add the custom form, along with the default one, to our settings:

WAGTAILSTREAMFORMS_FORM_TEMPLATES = (
    ('streamforms/form_block.html', _("Default Form Template")),  # default
    ('cms/custom_form.html', _("Custom Form Template")),
)

We still need a template for the contact page class. This is essentially just displaying the body of the contact page class, which is a StreamField. We're going to add messages from Wagtailstreamforms, if there are any; this is described in the docs. The code for the template is then:

{% extends 'account/base_card.html' %}

{% load i18n wagtailcore_tags %}

{% block card-header %}
    <h3>{{ page.title }}</h3>
    <p>{{ page.intro|richtext }}</p>
{% endblock %}

{% block card-body %}
    {% if messages %}
        {% for message in messages %}
            <p{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</p>
        {% endfor %}
    {% endif %}

    {% if page.body %}
        {%  include "streamfield.html" %}
    {% endif %}

{% endblock %}

In principle we have a working form builder now and we can migrate the database. However, we'd like to add a few things. First of all, let's make our application send a mail to a specific address when a form is submitted via the contact page. This is done with a submission hook in a separate file wagtailstreamforms_hooks.py, as is explained in the docs of Wagtail Streamforms; the code is literally written out. In it, the to-address (to which the email is sent) is fixed; it is nicer to make that configurable in the editor. That too is described in the docs, so let's do that first. In models.py create the model:

from wagtailstreamforms.models.abstract import AbstractFormSetting

class AdvancedFormSetting(AbstractFormSetting):
    to_address = models.EmailField()

and in your settings file set a parameter to point to this class:

WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL = 'cms.AdvancedFormSetting'

Now we're ready to create a file wagtailstreamforms_hooks.py and paste the code from the docs in there:

from django.conf import settings
from django.core.mail import EmailMessage
from django.template.defaultfilters import pluralize

from wagtailstreamforms.hooks import register

@register('process_form_submission')
def email_submission(instance, form):
    """ Send an email with the submission. """

    addresses = [instance.advanced_settings.to_address]
    content = ['Please see below submission\n', ]
    from_address = settings.DEFAULT_FROM_EMAIL
    subject = 'New Form Submission : %s' % instance.title

    # build up the email content
    for field, value in form.cleaned_data.items():
        if field in form.files:
            count = len(form.files.getlist(field))
            value = '{} file{}'.format(count, pluralize(count))
        elif isinstance(value, list):
            value = ', '.join(value)
        content.append('{}: {}'.format(field, value))
    content = '\n'.join(content)

    # create the email message
    email = EmailMessage(
        subject=subject,
        body=content,
        from_email=from_address,
        to=addresses
    )

    # attach any files submitted
    for field in form.files:
        for file in form.files.getlist(field):
            file.seek(0)
            email.attach(file.name, file.read(), file.content_type)

    # finally send the email
    email.send(fail_silently=True)

The only thing we have changed is the to-address, into our newly created instance.advanced_settings.to_address.

One final step is to add reCaptcha, which is also well documented. First we need reCaptcha keys to get access. Go to https://www.google.com/u/1/recaptcha/admin/create while logged in with your Google account and register your site; this is pretty straightforward. We will use reCaptcha v2, check here for the different versions. This will give you a public and private key that we will need. When you're testing this on your local computer (in my case a Mac), you might get an error [SSL: CERTIFICATE_VERIFY_FAILED]; check here for a solution.

Next, we need to install reCaptcha. There are a number of packages for that, we will stick to the one that is used in the docs of Wagtail Streamforms:

pip3 install django-recaptcha

Add it to requirements.txt and add captcha to INSTALLED_APPS. Put the developer public and private key in your settings, and enable noCaptcha:

# reCaptcha settings
RECAPTCHA_PUBLIC_KEY = '<your public reCaptcha key>'
RECAPTCHA_PRIVATE_KEY = '<your private reCaptcha key>'
# enable no captcha
NOCAPTCHA = True

Since you want to prevent your private key ending up in your repository, you might want to put this in the non-tracked part of your settings. Finally create a wagtailstreamforms_fields.py file and paste the code in the docs in it; it's a literal copy, so I won't repeat it here. It contains the definition of the reCaptcha field and registers it as such.

We're almost ready, but there is one issue: Wagtail Streamforms doesn't support Django 3.0 yet. "What?!", I hear you thinking, "and you tell me that now?". It actually sounds worse than it is. Django 3.0 removed the argument context from the method Field.from_db_value() and Wagtail Streamforms is still expecting it. The issue has been identified by the Wagtail Streamforms team and a simple solution has been proposed: in the method from_db_value change the argument context to context=None. It just hasn't been merged when writing this article. A possibility to handle this would be to fork the repository of Wagtail Streamforms, make the change in the fork and use that fork instead of the original wagtailstreamforms. For development we'll use the quick and dirty solution and make the change in our local environment. Not recommended for production! Let's keep a close watch at the resolution of that issue.

With that in mind, let's continue. Migrate the database if you haven't already done so and go to admin. Create a form with all the fields you like and then create a contact page with that form in it. Click on advanced to select email submission and/or save form submission. Try it: fill it in and send it, it should end up in your mailbox.

In a multilingual site, we obviously would like forms in different languages. For that have to recreate the same form in the other language; there is no way (yet) of clustering all translated forms in one set. As long as the number of forms is limited, this is not much of a problem. We can then just hook up the translated form to the respective translated page.

In case you have created a navigation menu, adding the contact page to it is simple: just go to the menu in admin and add the link to the page to it. Watch the video if you'd like to see how.

We're done with forms for now. If you're ready to test your application, read on.

Comment on this article (sign in first or confirm by name and email below)