1

I am working on a project that requires an upload of multiple images in one of the form wizard steps. The form wizard also has several models used for the wizard which I think complicates the whole process the more. Here is the relevant code:

models.py

from django.db import models
from django.contrib.auth.models import User
from location_field.models.plain import PlainLocationField
from PIL import Image
from django.core.validators import MaxValueValidator, MinValueValidator
from listing_admin_data.models import (Service, SubscriptionType, PropertySubCategory,
        PropertyFeatures, VehicleModel, VehicleBodyType, VehicleFuelType,
        VehicleColour, VehicleFeatures, BusinessAmenities, Currency
    )

class Listing(models.Model):
    listing_type_choices = [('P', 'Property'), ('V', 'Vehicle'), ('B', 'Business/Service'), ('E', 'Events')]

    listing_title = models.CharField(max_length=255)
    listing_type = models.CharField(choices=listing_type_choices, max_length=1, default='P')
    status = models.BooleanField(default=False)
    featured = models.BooleanField(default=False)
    city = models.CharField(max_length=255, blank=True)
    location = PlainLocationField(based_fields=['city'], zoom=7, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    expires_on = models.DateTimeField(auto_now=True)
    created_by = models.ForeignKey(User,
        on_delete=models.CASCADE, editable=False, null=True, blank=True
    )
    listing_owner = models.ForeignKey(User,
        on_delete=models.CASCADE, related_name='list_owner'
    )

    def __str__(self):
        return self.listing_title


def get_image_filename(instance, filename):
    title = instance.listing.listing_title
    slug = slugify(title)
    return "listings_pics/%s-%s" % (slug, filename)


class ListingImages(models.Model):
    listing = models.ForeignKey(Listing, on_delete=models.CASCADE)
    image_url = models.ImageField(upload_to=get_image_filename,
                              verbose_name='Listing Images')
    main_image = models.BooleanField(default=False)

    class Meta:
        verbose_name_plural = "Listing Images"

    def __str__(self):
        return f'{self.listing.listing_title} Image'


class Subscriptions(models.Model):
    subscription_type = models.ForeignKey(SubscriptionType, on_delete=models.CASCADE)
    subscription_date = models.DateTimeField(auto_now_add=True)
    subscription_amount = models.DecimalField(max_digits=6, decimal_places=2)
    subscribed_by = models.ForeignKey(User, on_delete=models.CASCADE)
    duration = models.PositiveIntegerField(default=0)
    listing_subscription = models.ManyToManyField(Listing)
    updated_at = models.DateTimeField(auto_now=True)
    status = models.BooleanField(default=False)

    class Meta:
        verbose_name_plural = "Subscriptions"

    def __str__(self):
        return f'{self.listing.listing_title} Subscription'


class Property(models.Model):
    sale_hire_choices = [('S', 'Sale'), ('R', 'Rent')]
    fully_furnished_choices = [('Y', 'Yes'), ('N', 'No')]

    listing = models.OneToOneField(Listing, on_delete=models.CASCADE)
    sub_category = models.ForeignKey(PropertySubCategory, on_delete=models.CASCADE)
    for_sale_rent = models.CharField(choices=sale_hire_choices, max_length=1, default=None)
    bedrooms = models.PositiveIntegerField(default=0)
    bathrooms = models.PositiveIntegerField(default=0)
    rooms = models.PositiveIntegerField(default=0)
    land_size = models.DecimalField(max_digits=10, decimal_places=2)
    available_from = models.DateField()
    car_spaces = models.PositiveIntegerField(default=0)
    fully_furnished = models.CharField(choices=fully_furnished_choices, max_length=1, default=None)
    desc = models.TextField()
    property_features = models.ManyToManyField(PropertyFeatures)
    price = models.DecimalField(max_digits=15, decimal_places=2)
    currency = models.ForeignKey(Currency, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    created_by = models.ForeignKey(User, on_delete=models.CASCADE)

    class Meta:
        verbose_name_plural = "Properties"

    def __str__(self):
        return f'{self.listing.listing_title}'

The forms for this app is as follows: forms.py

    from django import forms
from .models import Listing, Property, Vehicle, Business, ListingImages

class ListingDetails(forms.ModelForm):
    class Meta:
        model = Listing
        fields = ['listing_title', 'city', 'location']

class PropertyDetails1(forms.ModelForm):
    class Meta:
        model = Property
        fields = ['sub_category', 'for_sale_rent', 'bedrooms', 'bathrooms',
            'rooms', 'land_size', 'available_from', 'car_spaces', 'fully_furnished',
            'desc', 'currency', 'price'
        ]

class PropertyDetails2(forms.ModelForm):
    class Meta:
        model = Property
        fields = ['property_features']

class ListingImagesForm(forms.ModelForm):
    class Meta:
        model = ListingImages
        fields = ['image_url']

The view that handles all this, though not yet complete as I am still researching on the best way to save the data to the database is as shown below: views.py

    from django.shortcuts import render
import os
from .forms import ListingDetails, PropertyDetails1, PropertyDetails2, ListingImagesForm
from .models import ListingImages
from formtools.wizard.views import SessionWizardView
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django.forms import modelformset_factory
from django.contrib import messages
from django.http import HttpResponseRedirect

class PropertyView(SessionWizardView):
    ImageFormSet = modelformset_factory(ListingImages, form=ListingImagesForm, extra=3)
    template_name = "listings/create_property.html"
    formset = ImageFormSet(queryset=Images.objects.none())
    form_list = [ListingDetails, PropertyDetails1, PropertyDetails2, ListingImagesForm]
    file_storage = FileSystemStorage(location=os.path.join(settings.MEDIA_ROOT, 'media'))
    def done(self, form_list, **kwargs):
        return render(self.request, 'done.html', {
            'form_data': [form.cleaned_data for form in form_list],
        })

The template that is used to handle the form fields is as below: create_property.py

    <p>Step {{ wizard.steps.step1 }} of {{ wizard.steps.count }}</p>
<form action="" method="post">
    {% csrf_token %}
    <table>
    {{ wizard.management_form }}
    {% if wizard.form.forms %}
        {{ wizard.form.management_form }}
        {% for form in wizard.form.forms %}
            {{ form }}
        {% endfor %}
        {% else %}
            {% for field in wizard.form %}
                <div class="form-group">
                    <label for="{{ field.id_for_label }}">{{ field.label }}</label>
                    {{ field }}
                    <span class="message">{{ field.errors }}</span>
                </div>
            {% endfor %}
    {% endif %}
    </table>
    {% if wizard.steps.prev %}
    <div class="d-flex justify-content-around">
        <button name="wizard_goto_step" type="submit" class="btn btn-primary" value="{{ wizard.steps.first }}">First Step</button>
        <button name="wizard_goto_step" type="submit" class="btn btn-primary" value="{{ wizard.steps.prev }}">Previous Step</button>
    </div>
    {% endif %}

    <div class="d-flex justify-content-end col-12 mb-30 pl-15 pr-15">
        <input type="submit" value="{% trans "submit" %}"/>
    </div>
</form>

I have tried to attach all the relevant information for this to work.

The main problem I am facing is that the template doesn't give room for multiple uploads, and again, after I attach a single file that is provided and try to submit, I get a field cannot be empty error message.

I am still new to Django and trying to learn as I code, but so far, not much has been documented about the form wizard and the multiple image upload issues. Most of the posts available appear to be shoddy and only uses the contact form which does not store any details on the database.

japheth
  • 373
  • 1
  • 10
  • 34
  • At the very least your form needs `enctype="multipart/form-data"` within the `
    ` tag to enable file upload
    – HenryM Jun 25 '19 at 10:14
  • Sure, I think that was the problem causing the error of failing to submit the form with one image. Thank you for the pointer. Now the big problem I am facing is having to upload multiple images. I will appreciate any pointer to that. – japheth Jun 26 '19 at 06:58
  • I've not used formtools with a formset but it looks like this issue to me: https://github.com/django/django-formtools/issues/43 – HenryM Jun 26 '19 at 07:28
  • 1
    Hi. Did you ever work this out? I'm about to attempt something similar and I'd be keen to know how you solved this. – GerryDevine Feb 06 '20 at 07:19
  • @GerryDevine, sorry for the late response. I managed this using dropzone. It is simple and has better UI. – japheth Feb 13 '21 at 10:43

1 Answers1

1

The solution is from this Project

MultipleUpload.py

from django import forms

FILE_INPUT_CONTRADICTION = object()


class ClearableMultipleFilesInput(forms.ClearableFileInput):
    
    # Taken from:
    # https://stackoverflow.com/questions/46318587/django-uploading-multiple-files-list-of-files-needed-in-cleaned-datafile#answer-46409022

    def value_from_datadict(self, data, files, name):
        upload = files.getlist(name)  # files.get(name) in Django source

        if not self.is_required and forms.CheckboxInput().value_from_datadict(
                data, files, self.clear_checkbox_name(name)):

            if upload:
                # If the user contradicts themselves (uploads a new file AND
                # checks the "clear" checkbox), we return a unique marker
                # objects that FileField will turn into a ValidationError.
                return FILE_INPUT_CONTRADICTION
            # False signals to clear any existing value, as opposed to just None
            return False
        return upload


class MultipleFilesField(forms.FileField):
    # Taken from:
    # https://stackoverflow.com/questions/46318587/django-uploading-multiple-files-list-of-files-needed-in-cleaned-datafile#answer-46409022

    widget = ClearableMultipleFilesInput

    def clean(self, data, initial=None):
        # If the widget got contradictory inputs, we raise a validation error
        if data is FILE_INPUT_CONTRADICTION:
            raise forms.ValidationError(self.error_message['contradiction'], code='contradiction')
        # False means the field value should be cleared; further validation is
        # not needed.
        if data is False:
            if not self.required:
                return False
            # If the field is required, clearing is not possible (the widg    et
            # shouldn't return False data in that case anyway). False is not
            # in self.empty_value; if a False value makes it this far
            # it should be validated from here on out as None (so it will be
            # caught by the required check).
            data = None
        if not data and initial:
            return initial
        return data
enter code here
from django.core.files.uploadedfile import UploadedFile
from django.utils import six
from django.utils.datastructures import MultiValueDict


from formtools.wizard.storage.exceptions import NoFileStorageConfigured
from formtools.wizard.storage.base import BaseStorage


class MultiFileSessionStorage(BaseStorage):
    """
    Custom session storage to handle multiple files upload.
    """
    storage_name = '{}.{}'.format(__name__, 'MultiFileSessionStorage')

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.prefix not in self.request.session:
            self.init_data()

    ################################################################################################
    # Helper
    ################################################################################################

    def _get_data(self):
        self.request.session.modified = True
        return self.request.session[self.prefix]

    def _set_data(self, value):
        self.request.session[self.prefix] = value
        self.request.session.modified = True

    data = property(_get_data, _set_data)

    ################################################################################################
    # formtools.wizard.storage.base.BaseStorage API overrides
    ################################################################################################

    def reset(self):
        # Store unused temporary file names in order to delete them
        # at the end of the response cycle through a callback attached in
        # `update_response`.
        wizard_files = self.data[self.step_files_key]
        for step_files in six.itervalues(wizard_files):
            for file_list in six.itervalues(step_files):
                for step_file in file_list:
                    self._tmp_files.append(step_file['tmp_name'])
        self.init_data()

    def get_step_files(self, step):
        wizard_files = self.data[self.step_files_key].get(step, {})

        if wizard_files and not self.file_storage:
            raise NoFileStorageConfigured(
                "You need to define 'file_storage' in your "
                "wizard view in order to handle file uploads.")

        files = {}
        for field in wizard_files.keys():
            files[field] = {}
            uploaded_file_list = []

            for field_dict in wizard_files.get(field, []):
                field_dict = field_dict.copy()
                tmp_name = field_dict.pop('tmp_name')
                if(step, field, field_dict['name']) not in self._files:
                    self._files[(step, field, field_dict['name'])] = UploadedFile(
                        file=self.file_storage.open(tmp_name), **field_dict)
                uploaded_file_list.append(self._files[(step, field, field_dict['name'])])
            files[field] = uploaded_file_list

        return MultiValueDict(files) or MultiValueDict({})

    def set_step_files(self, step, files):
        if files and not self.file_storage:
            raise NoFileStorageConfigured(
                "You need to define 'file_storage' in your "
                "wizard view in order to handle file uploads.")

        if step not in self.data[self.step_files_key]:
            self.data[self.step_files_key][step] = {}

        for field in files.keys():
            self.data[self.step_files_key][step][field] = []
            for field_file in files.getlist(field):
                tmp_filename = self.file_storage.save(field_file.name, field_file)
                file_dict = {
                    'tmp_name': tmp_filename,
                    'name': field_file.name,
                    'content_type': field_file.content_type,
                    'size': field_file.size,
                    'charset': field_file.charset
                }
                self.data[self.step_files_key][step][field].append(file_dict)

form.py

from MultipleUpload import MultipleFilesField, ClearableMultipleFilesInput
class ListingImagesForm(forms.ModelForm):
    image_url = MultipleFilesField(widget=ClearableMultipleFilesInput(
    attrs={'multiple': True, 'accept':'.jpg,.jpeg,.png'}), label='Files')
    class Meta:
        model = ListingImages
        fields = ['image_url']

views.py

from MultipleUpload import MultiFileSessionStorage
class PropertyView(SessionWizardView):
    storage_name = MultiFileSessionStorage.storage_name
    ImageFormSet = modelformset_factory(ListingImages, form=ListingImagesForm, extra=3)
    template_name = "listings/create_property.html"
    formset = ImageFormSet(queryset=Images.objects.none())
    form_list = [(....), ('ListingImagesForm',ListingImagesForm)]
    file_storage = FileSystemStorage(location=os.path.join(settings.MEDIA_ROOT, 'media'))
    def done(self, form_list, **kwargs):
        cleaned_data = self.get_cleaned_data_for_step('ListingImagesForm')
        for f in cleaned_data.get('image_url',[]):
            instance = ListingImages(image_url=f, .....)  
            instance.save()
        return render(self.request, 'done.html', {
            'form_data': [form.cleaned_data for form in form_list],
        })
Yacine
  • 321
  • 2
  • 15