DEV Community

Cover image for Image Uploads in Wagtail Forms
LB (Ben Johnston)
LB (Ben Johnston)

Posted on • Updated on

Image Uploads in Wagtail Forms

For developers using the Wagtail CMS who want to add image upload fields.

Heads up - This is an update of my earlier post Image Uploads in Wagtail Forms which was written for Wagtail v1.12, this new post is written for v2.10/v2.11.

The Problem --- Your team are loving the custom form builder in Wagtail CMS and want to let people upload an image along with the form.

The Solution --- Define a new form field type that is selectable when editing fields in the CMS Admin, this field type will be called 'Upload Image'. This field should show up in the view as a normal upload field with restrictions on file type and size, just like the Wagtail Images system.

Note the Field Type: 'Upload Image' --- that is what we want to build.

Published form with an upload image field

Goal: When you add an 'Upload Image' field, it will show up on the form view for you.

Wagtail, Images and Forms

Skip ahead if you know the basics here.

Wagtail is a Content Management System (CMS) that is built on top of the Django Web Framework. What I love about Wagtail is that it embraces the Django ecosystem and way of doing things. It also has a really nice admin interface that makes it easy for users to interact with the content.

Wagtail has a built in interface and framework for uploading, storing and serving images. This is aptly named Wagtail Images, you can review the docs about Using Images in Templates or Advanced Image Usage for more information.

Wagtail comes with a great Form Builder module, it lets users build their own forms in the admin interface. These forms can have a series of fields such as Text, Multi-line Text, Email, URL, Checkbox, and others that build up a form page that can be viewed on the front end of the website. Users can customise the default value, whether the field is required and also some help text that relates to the field.

Before We Start

Before we start changing (breaking) things, it is important that you have the following items completed.

  1. Wagtail v2.10.x or v2.11.x up and running as per the main documentation.
  2. Wagtailforms module is installed, running and you have forms working. Remember to add 'wagtail.contrib.forms' to your INSTALLED_APPS.

Adding Image Upload Fields to Forms in Wagtail

Planning our Changes

We want to enable the following user interaction:

  1. The admin interface should provide the ability to edit an existing form and create a new form as normal.
  2. When editing a form page, there should be a new dropdown option on the 'Field Type' field called 'Upload Image'.
  3. The form page view should have one file upload field for every 'Upload Image' field that was defined in the admin.
  4. The form page view should accept images with the same restrictions as Wagtail Images (< 10mb, only PNG/JPG/GIF*).
  5. The form page view should require the image if the field is defined as 'required' in admin.
  6. When an image is valid, it should save this image into the Wagtail Images area.
  7. A link to the image should be saved to the form submission (aka form response), this will ensure it appears on emails or reports.

* Default GIF support is quite basic in Wagtail, if you want to support animated GIFs you should read these docs regarding Animated GIFs.

1. Extend the AbstractFormField Class

In your models file that contains your FormPage class definition, you should also have a definition for a FormField class. In the original definition, the AbstractFormField class uses a fixed tuple of FORM_FIELD_CHOICES. We need to override the field_type with an appended set of choices.

# models.py

from wagtail.contrib.forms.models import AbstractForm, AbstractFormField, FORM_FIELD_CHOICES

class FormField(AbstractFormField):

    field_type = models.CharField(
        verbose_name='field type',
        max_length=16,
        choices=list(FORM_FIELD_CHOICES) + [('image', 'Upload Image')]
    )

    page = ParentalKey('FormPage', related_name='form_fields', on_delete=models.CASCADE)

Enter fullscreen mode Exit fullscreen mode

In the above code you can see that we imported the original FORM_FIELD_CHOICES from wagtail.contrib.forms.models. We then converted it to a list, added our new field type and then this is used in the choices argument of the field_type field.

When you do this, you will need to make a migration, and run that migration. Test it out, the form in admin will now let you select this type, but it will not do much else yet.

2. Extend the FormBuilder Class

In your models file you will now need to create an extended form builder class. In the original definition the FormBuilder class builds a form based on the field_type list that is stored in each FormPage instance. We can follow the example in the docs about Adding a custom field type.

We will need to create a method that follows the convention based on the field name ('image' in our case) to a method name create_image_field which is then called and should return an instance of a Django form widget. Rather than building our own custom Image field that works with Wagtail, we can use their own WagtailImageField.

# models.py

from wagtail.contrib.forms.forms import FormBuilder

from wagtail.images.fields import WagtailImageField


class CustomFormBuilder(FormBuilder):

    def create_image_field(self, field, options):
        return WagtailImageField(**options)

Enter fullscreen mode Exit fullscreen mode

In the above code, we have imported FormBuilder from wagtail.contrib.forms.forms and WagtailImageField from wagtail.images.fields, then created our own custom FormBuilder with a new class. We have added a method create_image_field that returns a created WagtailImageField, passing in any options provided.

3. Set the FormPage class to use CustomFormBuilder

This step is pretty straight forward, we want to override the form_builder definition in our FormPage model. This is a very nifty way that Wagtail enables you to override the form_builder you use.

# models.py

from wagtail.contrib.forms.models import AbstractForm

class FormPage(AbstractForm):

    form_builder = CustomFormBuilder

    #... rest of the FormPage definition

Enter fullscreen mode Exit fullscreen mode

Form Page editor with the 'upload image' field type available

4. Update form page template to accept File Data

The form page view should have a <form /> tag in it, the implementation suggested by Wagtail does not allow files data to be submitted in the form.

<!-- templates/form_page.html -->

{% extends "base.html" %}

{% load wagtailcore_tags %}

{% block content %}

    {{ self.intro }}

    <form action="{% pageurl self %}" method="POST" enctype="multipart/form-data">
        {% csrf_token %}
        {{ form.as_p }}
        <input type="submit" />
    </form>

{% endblock %}
Enter fullscreen mode Exit fullscreen mode

The only difference to the basic form is that we have added enctype="multipart/form-data" to our form attributes. If you do not do this you will never get any files sent through the request and no errors to advise you why.

For more information about why we need to do this, you can view the Django Docs File Uploads page and have a deep dive into the enctype form attribute on MDN.

5. Add ability to select a collection for uploaded images

When uploading images via the admin interface, there is an option to add each image to a collection, this defaults to 'Root' and these act like folders for your images.

Rather than just dumping all uploaded images from form submissions into 'Root' we want to give the user the option to determine which Collection the images for each page form will be added to.

# models.py

from wagtail.admin.edit_handlers import FieldPanel
from wagtail.core.models import Collection


class FormPage(AbstractForm):

    form_builder = CustomFormBuilder

    # other fields...

    uploaded_image_collection = models.ForeignKey(
        'wagtailcore.Collection',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )

    # content_panels...

    settings_panels = AbstractForm.settings_panels + [
        FieldPanel('uploaded_image_collection')
    ]


    def get_uploaded_image_collection(self):
        """
        Returns a Wagtail Collection, using this form's saved value if present,
        otherwise returns the 'Root' Collection.
        """
        collection = self.uploaded_image_collection
        return collection or Collection.get_first_root_node()

Enter fullscreen mode Exit fullscreen mode

In the code above we import the Collections model, added a new field to our FormPage model called uploaded_image_collection which is a ForeignKey relation to the 'wagtailCore.Collection' model.

We also added a class method to retrieve this from the Page and fall back to the root collection via the get_first_root_node method (as Wagtail Collections use Treebeard to define a tree like structure).

Once this code step is completed, you will need to make a migration, and run that migration.

Selecting an image collection via the Settings tab

6. Process the Image (file) Data after Validation

We will now override the process_form_submission on our FormPage class. The original definition of the process_form_submission method has no notion of processing anything other than the request.POST data. It will simply convert the cleaned data to JSON for storing on the form submission instance. We will iterate through each field and find any instances of WagtailImageField then get the data, create a new Wagtail Image with that file data, finally we will store a link to the image in the response.

# models.py

import json
from os.path import splitext

from django.core.serializers.json import DjangoJSONEncoder

from wagtail.images import get_image_model


class FormPage(AbstractForm):

    form_builder = CustomFormBuilder

    # fields & panels definitions...

    @staticmethod
    def get_image_title(filename):
        """
        Generates a usable title from the filename of an image upload.
        Note: The filename will be provided as a 'path/to/file.jpg'
        """

        if filename:
            result = splitext(filename)[0]
            result = result.replace('-', ' ').replace('_', ' ')
            return result.title()
        return ''

    def process_form_submission(self, form):
        """
        Processes the form submission, if an Image upload is found, pull out the
        files data, create an actual Wgtail Image and reference its ID only in the
        stored form response.
        """

        cleaned_data = form.cleaned_data

        for name, field in form.fields.items():
            if isinstance(field, WagtailImageField):
                image_file_data = cleaned_data[name]
                if image_file_data:
                    ImageModel = get_image_model()

                    kwargs = {
                        'file': cleaned_data[name],
                        'title': self.get_image_title(cleaned_data[name].name),
                        'collection': self.get_uploaded_image_collection(),
                    }

                    if form.user and not form.user.is_anonymous:
                        kwargs['uploaded_by_user'] = form.user

                    image = ImageModel(**kwargs)
                    image.save()
                    # saving the image id
                    # alternatively we can store a path to the image via image.get_rendition
                    cleaned_data.update({name: image.pk})
                else:
                    # remove the value from the data
                    del cleaned_data[name]

        submission = self.get_submission_class().objects.create(
            form_data=json.dumps(form.cleaned_data, cls=DjangoJSONEncoder), # note: Wagtail 3.0 & beyond will no longer need to wrap this in json.dumps as it uses Django's JSONField under the hood now - https://docs.wagtail.org/en/stable/releases/3.0.html#replaced-form-data-textfield-with-jsonfield-in-abstractformsubmission
            page=self,
        )

        # important: if extending AbstractEmailForm, email logic must be re-added here
        # if self.to_address:
        #    self.send_mail(form)

        return submission
Enter fullscreen mode Exit fullscreen mode

Once this is applied, you should be able to submit a form response with an uploaded image.

Published form with an upload image field

A few items of note here:

  • Using get_image_model is the best practice way to get the Image Model that Wagtail is using.
  • cleaned_data contains the File Data (for any files), the Django form module does this for us. File Data cannot be parsed by the JSON parser, hence us having to process into a URL or Image ID for these cases.
  • The staticmethod get_image_title can look like whatever you want, I stripped out dashes and made the file title case. You do not have to do this but you do have to ensure there is some title when inserting a WagtailImage.
  • If our FormPage is actually extending AbstractEmailForm (ie. the form submits AND sends an email) we must ensure that the send_mail code is added.
  • You must use cleaned_data.update to save a JSON seralizable reference to your image, hence the file data will not work.

7. Viewing the image via the form submissions listing

The final step is to provide a way for this image to be easily viewed in the submission listing view, we can do this by customising how this list generates.

We have stored an id of the image but we want to use image.get_rendition, which is a very useful function detailed in the Wagtail Documentation. This function mimics the template helper but can be used in Python. By default the URL will be relative (it will not contain the http/https, or the domain), this will mean links sent to email will not work. It is up to you to work out how to best solve this if it is an issue.

# models.py

from django.utils.html import format_html
from django.urls import reverse

from wagtail.contrib.forms.views import SubmissionsListView


class CustomSubmissionsListView(SubmissionsListView):

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        if not self.is_export:
            # generate a list of field types, the first being the injected 'submission date'
            field_types = ['submission_date'] + [field.field_type for field in self.form_page.get_form_fields()]
            data_rows = context['data_rows']

            ImageModel = get_image_model()

            for data_row in data_rows:

                fields = data_row['fields']

                for idx, (value, field_type) in enumerate(zip(fields, field_types)):
                    if field_type == 'image' and value:
                        image = ImageModel.objects.get(pk=value)
                        rendition = image.get_rendition('fill-100x75|jpegquality-40')
                        preview_url = rendition.url
                        url = reverse('wagtailimages:edit', args=(image.id,))
                        # build up a link to the image, using the image title & id
                        fields[idx] = format_html(
                            "<a href='{}'><img alt='Uploaded image - {}' src='{}' />{} ({})</a>",
                            url,
                            image.title,
                            preview_url,
                            image.title,
                            value
                        )

        return context

class FormPage(AbstractForm):

    form_builder = CustomFormBuilder
    submissions_list_view_class = CustomSubmissionsListView # added

Enter fullscreen mode Exit fullscreen mode

In the code above we have added a new CustomSubmissionsListView that extends the Wagtail SubmissionsListView which will have a custom get_context_data method. In this method we call the original get_context_data to get the generated context data.

Then we check if we are showing the submissions to the user (instead of exporting them) and map through each submission row, checking with values are images and updating the shown value with some HTML. This HTML will contain a preview of the image (using rendition) with some description based on the title and id within a link to the admin page for that image.

Submission listing view with image previews

Finishing Up

Your Form models.py file will now look something like the following.

Full code snippet
# models.py

import json
from os.path import splitext

from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.utils.html import format_html
from django.urls import reverse

from modelcluster.fields import ParentalKey

from wagtail.admin.edit_handlers import (
    FieldPanel,
    FieldRowPanel,
    InlinePanel,
    MultiFieldPanel,
    PageChooserPanel,
    StreamFieldPanel,
)
from wagtail.core.models import Collection
from wagtail.contrib.forms.forms import FormBuilder
from wagtail.contrib.forms.models import AbstractForm, AbstractFormField, FORM_FIELD_CHOICES
from wagtail.contrib.forms.views import SubmissionsListView
from wagtail.images import get_image_model
from wagtail.images.fields import WagtailImageField


class CustomFormBuilder(FormBuilder):

    def create_image_field(self, field, options):
        return WagtailImageField(**options)


class CustomSubmissionsListView(SubmissionsListView):

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        if not self.is_export:

            # generate a list of field types, the first being the injected 'submission date'
            field_types = ['submission_date'] + [field.field_type for field in self.form_page.get_form_fields()]
            data_rows = context['data_rows']
            ImageModel = get_image_model()

            for data_row in data_rows:

                fields = data_row['fields']

                for idx, (value, field_type) in enumerate(zip(fields, field_types)):
                    if field_type == 'image' and value:
                        image = ImageModel.objects.get(pk=value)
                        rendition = image.get_rendition('fill-100x75|jpegquality-40')
                        preview_url = rendition.url
                        url = reverse('wagtailimages:edit', args=(image.id,))
                        # build up a link to the image, using the image title & id
                        fields[idx] = format_html(
                            "<a href='{}'><img alt='Uploaded image - {}' src='{}' />{} ({})</a>",
                            url,
                            image.title,
                            preview_url,
                            image.title,
                            value
                        )

        return context


class FormField(AbstractFormField):

    field_type = models.CharField(
        verbose_name='field type',
        max_length=16,
        choices=list(FORM_FIELD_CHOICES) + [('image', 'Upload Image')]
    )

    page = ParentalKey('FormPage', related_name='form_fields', on_delete=models.CASCADE)


class FormPage(AbstractForm):

    form_builder = CustomFormBuilder
    submissions_list_view_class = CustomSubmissionsListView

    # ... fields

    uploaded_image_collection = models.ForeignKey(
        'wagtailcore.Collection',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )

    content_panels = AbstractForm.content_panels + [
        # ... panels
    ]

    settings_panels = AbstractForm.settings_panels + [
        FieldPanel('uploaded_image_collection')
    ]

    def get_uploaded_image_collection(self):
        """
        Returns a Wagtail Collection, using this form's saved value if present,
        otherwise returns the 'Root' Collection.
        """
        collection = self.uploaded_image_collection

        return collection or Collection.get_first_root_node()

    @staticmethod
    def get_image_title(filename):
        """
        Generates a usable title from the filename of an image upload.
        Note: The filename will be provided as a 'path/to/file.jpg'
        """

        if filename:
            result = splitext(filename)[0]
            result = result.replace('-', ' ').replace('_', ' ')
            return result.title()
        return ''

    def process_form_submission(self, form):
        """
        Processes the form submission, if an Image upload is found, pull out the
        files data, create an actual Wgtail Image and reference its ID only in the
        stored form response.
        """

        cleaned_data = form.cleaned_data

        for name, field in form.fields.items():
            if isinstance(field, WagtailImageField):
                image_file_data = cleaned_data[name]
                if image_file_data:
                    ImageModel = get_image_model()

                    kwargs = {
                        'file': cleaned_data[name],
                        'title': self.get_image_title(cleaned_data[name].name),
                        'collection': self.get_uploaded_image_collection(),
                    }

                    if form.user and not form.user.is_anonymous:
                        kwargs['uploaded_by_user'] = form.user

                    image = ImageModel(**kwargs)
                    image.save()
                    # saving the image id
                    # alternatively we can store a path to the image via image.get_rendition
                    cleaned_data.update({name: image.pk})
                else:
                    # remove the value from the data
                    del cleaned_data[name]

        submission = self.get_submission_class().objects.create(
            form_data=json.dumps(form.cleaned_data, cls=DjangoJSONEncoder),
            page=self,
        )

        # important: if extending AbstractEmailForm, email logic must be re-added here
        # if self.to_address:
        #    self.send_mail(form)

        return submission

Enter fullscreen mode Exit fullscreen mode

Forms can now have one or more Image Upload fields that are defined by the CMS editors. These images will be available in Admin in the Images section and can be used throughout the rest of Wagtail. You also get all the benefits that come with Wagtail Images like search indexing, usage in templates and URLS for images of various compressed sizes.

Published form with an upload image field

The Admin view of form responses will now show whatever you store from the clean_data.

Let me know if you run into issues or find some typos/bugs in this article. Thank you to the amazing team at Torchbox and all the developers of Wagtail for making this amazing tool. Show your support of Wagtail by starring the Wagtail repo on Github.

You can see all the original code changes in Github via the image-uploads branch.

One suggestion from the comments: It's a good idea to add a post_delete signal on the form submission model. So that when you delete an entry from the submission list, it will also delete the uploaded image.

Thanks to my friend Adam for helping me proof this.

Top comments (9)

Collapse
 
alex_lanafrutuoso_17c240 profile image
Alex Lana Frutuoso • Edited

Hi! I tried it but it now work. The image upload field is loaded on form but when I sent the form, the page is reloaded without any message and the data is not sent. If the image upload field is not required, the form is sent but the image is not.

Don't have logs... some suggestion to solve it?

This is the models.py:

from __future__ import unicode_literals

import json
from os.path import splitext

from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.utils.html import format_html
from django.urls import reverse

from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel

from wagtail.admin.edit_handlers import (
    FieldPanel,
    FieldRowPanel,
    InlinePanel,
    MultiFieldPanel,
    PageChooserPanel,
    StreamFieldPanel,
)
from wagtail.core.fields import RichTextField, StreamField
from wagtail.core.models import Collection, Page
from wagtail.contrib.forms.forms import FormBuilder
from wagtail.contrib.forms.models import AbstractEmailForm, AbstractFormField, FORM_FIELD_CHOICES
from wagtail.contrib.forms.models import AbstractForm
from wagtail.contrib.forms.views import SubmissionsListView
from wagtail.images import get_image_model
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.images.fields import WagtailImageField
from wagtail.search import index
from wagtail.snippets.models import register_snippet

from .blocks import BaseStreamBlock


@register_snippet
class People(index.Indexed, ClusterableModel):
    """
    A Django model to store People objects.
    It uses the `@register_snippet` decorator to allow it to be accessible
    via the Snippets UI (e.g. /admin/snippets/base/people/)

    `People` uses the `ClusterableModel`, which allows the relationship with
    another model to be stored locally to the 'parent' model (e.g. a PageModel)
    until the parent is explicitly saved. This allows the editor to use the
    'Preview' button, to preview the content, without saving the relationships
    to the database.
    https://github.com/wagtail/django-modelcluster
    """
    first_name = models.CharField("First name", max_length=254)
    last_name = models.CharField("Last name", max_length=254)
    job_title = models.CharField("Job title", max_length=254)

    image = models.ForeignKey(
        'wagtailimages.Image',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+'
    )

    panels = [
        MultiFieldPanel([
            FieldRowPanel([
                FieldPanel('first_name', classname="col6"),
                FieldPanel('last_name', classname="col6"),
            ])
        ], "Name"),
        FieldPanel('job_title'),
        ImageChooserPanel('image')
    ]

    search_fields = [
        index.SearchField('first_name'),
        index.SearchField('last_name'),
    ]

    @property
    def thumb_image(self):
        # Returns an empty string if there is no profile pic or the rendition
        # file can't be found.
        try:
            return self.image.get_rendition('fill-50x50').img_tag()
        except:  # noqa: E722 FIXME: remove bare 'except:'
            return ''

    def __str__(self):
        return '{} {}'.format(self.first_name, self.last_name)

    class Meta:
        verbose_name = 'Person'
        verbose_name_plural = 'People'


@register_snippet
class FooterText(models.Model):
    """
    This provides editable text for the site footer. Again it uses the decorator
    `register_snippet` to allow it to be accessible via the admin. It is made
    accessible on the template via a template tag defined in base/templatetags/
    navigation_tags.py
    """
    body = RichTextField()

    panels = [
        FieldPanel('body'),
    ]

    def __str__(self):
        return "Footer text"

    class Meta:
        verbose_name_plural = 'Footer Text'


class StandardPage(Page):
    """
    A generic content page. On this demo site we use it for an about page but
    it could be used for any type of page content that only needs a title,
    image, introduction and body field
    """

    introduction = models.TextField(
        help_text='Text to describe the page',
        blank=True)
    image = models.ForeignKey(
        'wagtailimages.Image',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+',
        help_text='Landscape mode only; horizontal width between 1000px and 3000px.'
    )
    body = StreamField(
        BaseStreamBlock(), verbose_name="Page body", blank=True
    )
    content_panels = Page.content_panels + [
        FieldPanel('introduction', classname="full"),
        StreamFieldPanel('body'),
        ImageChooserPanel('image'),
    ]


class HomePage(Page):
    """
    The Home Page. This looks slightly more complicated than it is. You can
    see if you visit your site and edit the homepage that it is split between
    a:
    - Hero area
    - Body area
    - A promotional area
    - Moveable featured site sections
    """

    # Hero section of HomePage
    image = models.ForeignKey(
        'wagtailimages.Image',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+',
        help_text='Homepage image'
    )
    hero_text = models.CharField(
        max_length=255,
        help_text='Write an introduction for the bakery'
    )
    hero_cta = models.CharField(
        verbose_name='Hero CTA',
        max_length=255,
        help_text='Text to display on Call to Action'
    )
    hero_cta_link = models.ForeignKey(
        'wagtailcore.Page',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+',
        verbose_name='Hero CTA link',
        help_text='Choose a page to link to for the Call to Action'
    )

    # Body section of the HomePage
    body = StreamField(
        BaseStreamBlock(), verbose_name="Home content block", blank=True
    )

    # Promo section of the HomePage
    promo_image = models.ForeignKey(
        'wagtailimages.Image',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+',
        help_text='Promo image'
    )
    promo_title = models.CharField(
        null=True,
        blank=True,
        max_length=255,
        help_text='Title to display above the promo copy'
    )
    promo_text = RichTextField(
        null=True,
        blank=True,
        help_text='Write some promotional copy'
    )

    # Featured sections on the HomePage
    # You will see on templates/base/home_page.html that these are treated
    # in different ways, and displayed in different areas of the page.
    # Each list their children items that we access via the children function
    # that we define on the individual Page models e.g. BlogIndexPage
    featured_section_1_title = models.CharField(
        null=True,
        blank=True,
        max_length=255,
        help_text='Title to display above the promo copy'
    )
    featured_section_1 = models.ForeignKey(
        'wagtailcore.Page',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+',
        help_text='First featured section for the homepage. Will display up to '
        'three child items.',
        verbose_name='Featured section 1'
    )

    featured_section_2_title = models.CharField(
        null=True,
        blank=True,
        max_length=255,
        help_text='Title to display above the promo copy'
    )
    featured_section_2 = models.ForeignKey(
        'wagtailcore.Page',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+',
        help_text='Second featured section for the homepage. Will display up to '
        'three child items.',
        verbose_name='Featured section 2'
    )

    featured_section_3_title = models.CharField(
        null=True,
        blank=True,
        max_length=255,
        help_text='Title to display above the promo copy'
    )
    featured_section_3 = models.ForeignKey(
        'wagtailcore.Page',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+',
        help_text='Third featured section for the homepage. Will display up to '
        'six child items.',
        verbose_name='Featured section 3'
    )

    content_panels = Page.content_panels + [
        MultiFieldPanel([
            ImageChooserPanel('image'),
            FieldPanel('hero_text', classname="full"),
            MultiFieldPanel([
                FieldPanel('hero_cta'),
                PageChooserPanel('hero_cta_link'),
            ]),
        ], heading="Hero section"),
        MultiFieldPanel([
            ImageChooserPanel('promo_image'),
            FieldPanel('promo_title'),
            FieldPanel('promo_text'),
        ], heading="Promo section"),
        StreamFieldPanel('body'),
        MultiFieldPanel([
            MultiFieldPanel([
                FieldPanel('featured_section_1_title'),
                PageChooserPanel('featured_section_1'),
            ]),
            MultiFieldPanel([
                FieldPanel('featured_section_2_title'),
                PageChooserPanel('featured_section_2'),
            ]),
            MultiFieldPanel([
                FieldPanel('featured_section_3_title'),
                PageChooserPanel('featured_section_3'),
            ]),
        ], heading="Featured homepage sections", classname="collapsible")
    ]

    def __str__(self):
        return self.title


class GalleryPage(Page):
    """
    This is a page to list locations from the selected Collection. We use a Q
    object to list any Collection created (/admin/collections/) even if they
    contain no items. In this demo we use it for a GalleryPage,
    and is intended to show the extensibility of this aspect of Wagtail
    """

    introduction = models.TextField(
        help_text='Text to describe the page',
        blank=True)
    image = models.ForeignKey(
        'wagtailimages.Image',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+',
        help_text='Landscape mode only; horizontal width between 1000px and '
        '3000px.'
    )
    body = StreamField(
        BaseStreamBlock(), verbose_name="Page body", blank=True
    )
    collection = models.ForeignKey(
        Collection,
        limit_choices_to=~models.Q(name__in=['Root']),
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        help_text='Select the image collection for this gallery.'
    )

    content_panels = Page.content_panels + [
        FieldPanel('introduction', classname="full"),
        StreamFieldPanel('body'),
        ImageChooserPanel('image'),
        FieldPanel('collection'),
    ]

    # Defining what content type can sit under the parent. Since it's a blank
    # array no subpage can be added
    subpage_types = []


class CustomFormBuilder(FormBuilder):

    def create_image_field(self, field, options):
        return WagtailImageField(**options)


class CustomSubmissionsListView(SubmissionsListView):

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        if not self.is_export:
            # generate a list of field types, the first being the injected 'submission date'
            field_types = ['submission_date'] + [field.field_type for field in self.form_page.get_form_fields()]
            data_rows = context['data_rows']

            ImageModel = get_image_model()

            for data_row in data_rows:

                fields = data_row['fields']

                for idx, (value, field_type) in enumerate(zip(fields, field_types)):
                    if field_type == 'image' and value:
                        image = ImageModel.objects.get(pk=value)
                        rendition = image.get_rendition('fill-100x75|jpegquality-40')
                        preview_url = rendition.url
                        url = reverse('wagtailimages:edit', args=(image.id,))
                        # build up a link to the image, using the image title & id
                        fields[idx] = format_html(
                            "<a href='{}'><img alt='Uploaded image - {}' src='{}' />{} ({})</a>",
                            url,
                            image.title,
                            preview_url,
                            image.title,
                            value
                        )

        return context


class FormField(AbstractFormField):
    field_type = models.CharField(
        verbose_name='field type',
        max_length=16,
        choices=list(FORM_FIELD_CHOICES) + [('image', 'Upload Image')]
    )

    page = ParentalKey('FormPage', related_name='form_fields', on_delete=models.CASCADE)


class FormPage(AbstractEmailForm):

    form_builder = CustomFormBuilder
    submissions_list_view_class = CustomSubmissionsListView # added

    image = models.ForeignKey(
        'wagtailimages.Image',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+'
    )
    body = StreamField(BaseStreamBlock())
    thank_you_text = RichTextField(blank=True)

    uploaded_image_collection = models.ForeignKey(
        'wagtailcore.Collection',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )

    # Note how we include the FormField object via an InlinePanel using the
    # related_name value
    content_panels = AbstractEmailForm.content_panels + [
        ImageChooserPanel('image'),
        StreamFieldPanel('body'),
        InlinePanel('form_fields', label="Form fields"),
        FieldPanel('thank_you_text', classname="full"),
        MultiFieldPanel([
            FieldRowPanel([
                FieldPanel('from_address', classname="col6"),
                FieldPanel('to_address', classname="col6"),
            ]),
            FieldPanel('subject'),
        ], "Email"),
    ]

    settings_panels = AbstractForm.settings_panels + [
        FieldPanel('uploaded_image_collection')
    ]

    def get_uploaded_image_collection(self):
        """
        Returns a Wagtail Collection, using this form's saved value if present,
        otherwise returns the 'Root' Collection.
        """
        collection = self.uploaded_image_collection
        return collection or Collection.get_first_root_node()


    @staticmethod
    def get_image_title(filename):
        """
        Generates a usable title from the filename of an image upload.
        Note: The filename will be provided as a 'path/to/file.jpg'
        """

        if filename:
            result = splitext(filename)[0]
            result = result.replace('-', ' ').replace('_', ' ')
            return result.title()
        return ''

    def process_form_submission(self, form):
        """
        Processes the form submission, if an Image upload is found, pull out the
        files data, create an actual Wgtail Image and reference its ID only in the
        stored form response.
        """

        cleaned_data = form.cleaned_data

        for name, field in form.fields.items():
            if isinstance(field, WagtailImageField):
                image_file_data = cleaned_data[name]
                if image_file_data:
                    ImageModel = get_image_model()

                    kwargs = {
                        'file': cleaned_data[name],
                        'title': self.get_image_title(cleaned_data[name].name),
                        'collection': self.get_uploaded_image_collection(),
                    }

                    if form.user and not form.user.is_anonymous:
                        kwargs['uploaded_by_user'] = form.user

                    image = ImageModel(**kwargs)
                    image.save()
                    # saving the image id
                    # alternatively we can store a path to the image via image.get_rendition
                    cleaned_data.update({name: image.pk})
                else:
                    # remove the value from the data
                    del cleaned_data[name]

        submission = self.get_submission_class().objects.create(
            form_data=json.dumps(form.cleaned_data, cls=DjangoJSONEncoder),
            page=self,
        )

        # important: if extending AbstractEmailForm, email logic must be re-added here
        # if self.to_address:
        #    self.send_mail(form)

        return submission
Enter fullscreen mode Exit fullscreen mode
Collapse
 
lb profile image
LB (Ben Johnston) • Edited

Hey. Sorry for the very late reply. I don't seem to get notified of comments. Did you end up solving this? Maybe check your form element to ensure it allows files. enctype="multipart/form-data">

Also. What version of Wagtail and Django are you using?

Collapse
 
royanezp profile image
Rodrigo Yáñez

Thanks for this tutorial, it seems to be what I'm looking for but I don't understand this part:

In your models file that contains your FormPage class definition, you should also have a definition for a FormField class

Currently what I'm trying to do is including an image field in the Wagtail admin, settings > user. But I don't have a FormPage model in my models.py file.

What I have in models.py is a CustomUser model with a profile_pic. I added custom admins in wagtail but the image field button is not working.

Collapse
 
tomh profile image
UDAR-TomH • Edited

Hello, there is a typo in step 2. "create_date_field" should be "create_image_field".

Also! In step 6, in process_form_submission, you no longer need to use json.dumps when setting the form_data. The line can now look like this:
form_data=form.cleaned_data,

and one last thing: it's a good idea to add a 'post_delete' signal on the form submission model, so that when you delete an entry from the submission list, it will also delete the uploaded image.

Collapse
 
lb profile image
LB (Ben Johnston)

Thanks - I have fixed these up now (except for the post_delete bit - I will just put that in the notes at the end).

Collapse
 
hazho profile image
Hazho Human

based on your codes, while you try to download as xlsx, there will be an error raising up:
".../models.py", line 260, in get_context_data
data_rows = context['data_rows']
KeyError: 'data_rows

Collapse
 
lb profile image
LB (Ben Johnston)

what happens if you replace the get_context_data on CustomSubmissionsListView with the following instead?

Does this fix it, have not tried in running code yet but I think this should do the trick. let me know & I can update the post.

class CustomSubmissionsListView(SubmissionsListView):

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # moved the field_types & data_rows setup to inside the if branch below

        if not self.is_export:
            # generate a list of field types, the first being the injected 'submission date'
            field_types = ['submission_date'] + [field.field_type for field in self.form_page.get_form_fields()]
            data_rows = context['data_rows']

            ImageModel = get_image_model()

            for data_row in data_rows:

                fields = data_row['fields']

                for idx, (value, field_type) in enumerate(zip(fields, field_types)):
                    if field_type == 'image' and value:
                        image = ImageModel.objects.get(pk=value)
                        rendition = image.get_rendition('fill-100x75|jpegquality-40')
                        preview_url = rendition.url
                        url = reverse('wagtailimages:edit', args=(image.id,))
                        # build up a link to the image, using the image title & id
                        fields[idx] = format_html(
                            "<a href='{}'><img alt='Uploaded image - {}' src='{}' />{} ({})</a>",
                            url,
                            image.title,
                            preview_url,
                            image.title,
                            value
                        )

        return context
Enter fullscreen mode Exit fullscreen mode
Collapse
 
lb profile image
LB (Ben Johnston)

Thanks Hazho. I will give that a go and try to work out what's wrong.

Collapse
 
lb profile image
LB (Ben Johnston)

Just tested the changes I suggested below, they fix the problem, post is updated and git repo branch is updated. Hopefully that helps :)