Rock IT

Multiple forms on one page in Django

Recently, I managed to solve this common problem in a general way that may be useful to others. Generally, it's a drop-in replacement for a FormView, that supports multiple forms on the same page (with just a minor change in template).

Use case

I wanted to display dynamic number of forms, each performing specific action. Site administrators were allowed to use every form, whereas moderators could see just a subset of them. All logic was stored in a form, for example sending email to user or performing some other business logic. Thanks to that, I could make Views as stupid as possible - just basic displaying and validation of selected form.

My goals:

  • I wanted to position all forms on the same page (in rows)
  • Validate just submitted one
  • Have the ability to decide which forms should be displayed to user
  • Easily add new forms
  • Compatibility with existing FormView

Solution

Firstly, let's take a look how we would like to use our View (repository with example code can be found here):

# forms in forms.py file
class ContactForm(forms.Form):
    name = forms.CharField(max_length=60)
    message = forms.CharField(max_length=200, widget=forms.TextInput)

class SubscriptionForm(forms.Form):
    email = forms.EmailField()
    want_spam = forms.BooleanField(required=False)

class SuggestionForm(forms.Form):
    text = forms.CharField(max_length=200, widget=forms.TextInput)
    type = forms.ChoiceField(choices=[('bug', 'Bug'), ('feature', 'Feature')])

class GlobalMessageForm(forms.Form):
    staff_only = True
    global_message = forms.CharField(max_length=200, widget=forms.TextInput)


# View with multiple forms, inheriting our Generic MultipleFormsView
class MultipleFormsDemoView(MultipleFormsView):
    template_name = 'forms.html'
    success_url = '/'

    # here we specify all forms that should be displayed
    forms_classes = [
        forms.GlobalMessageForm,
        forms.ContactForm,
        forms.SubscriptionForm,
        forms.SuggestionForm
    ]

    def get_forms_classes(self):
        # we hide staff_only forms from not-staff users
        # our goal no. 3 about dynamic amount list of forms 
        forms_classes = super(MultipleFormsDemoView, self).get_forms_classes()
        user = self.request.user
        if not user.is_authenticated() or not user.is_staff:
            return list(filter(lambda form: not getattr(form, 'staff_only', False), forms_classes))
        return forms_classes

    def form_valid(self, form): 
        print("yay it's valid!")
        return super(MultipleFormsDemoView, self).form_valid(form)

forms.html file:

<html>
  <head></head>
  <body>  
  {% for form in forms %}
    <form method="post">
      {% csrf_token %}
      {{ form }}
      <input type="hidden" name="selected_form" value="{{ forloop.counter0 }}">
      <button type="submit">Submit</button>
    </form>
  {% endfor %}
  </body>
</html>

I think that code above is self-explanatory, except hidden_field part. We need to somehow decide which form was submitted, that's why we're adding it, with proper 0-based index as a value.

Finally, Let's take a look at the MultipleFormsMixin code (it's also available as a gist here):

from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect
from django.utils.encoding import force_text
from django.views.generic.base import ContextMixin


class MultipleFormsMixin(ContextMixin):
    """
    A mixin that provides a way to show and handle multiple forms in a request.
    It's almost fully-compatible with regular FormsMixin
    """

    initial = {}
    forms_classes = []
    success_url = None
    prefix = None
    active_form_keyword = "selected_form"

    def get_initial(self):
        """
        Returns the initial data to use for forms on this view.
        """
        return self.initial.copy()

    def get_prefix(self):
        """
        Returns the prefix to use for forms on this view
        """
        return self.prefix

    def get_forms_classes(self):
        """
        Returns the forms classes to use in this view
        """
        return self.forms_classes

    def get_active_form_number(self):
        """
        Returns submitted form index in available forms list
        """
        if self.request.method in ('POST', 'PUT'):
            try:
                return int(self.request.POST[self.active_form_keyword])
            except (KeyError, ValueError):
                raise ImproperlyConfigured(
                    "You must include hidden field with field index in every form!")

    def get_forms(self, active_form=None):
        """
        Returns instances of the forms to be used in this view.
        Includes provided `active_form` in forms list.
        """
        all_forms_classes = self.get_forms_classes()
        all_forms = [
            form_class(**self.get_form_kwargs()) 
            for form_class in all_forms_classes]
        if active_form:
            active_form_number = self.get_active_form_number()
            all_forms[active_form_number] = active_form
        return all_forms

    def get_form(self):
        """
        Returns active form. Works only on `POST` and `PUT`, otherwise returns None.
        """
        active_form_number = self.get_active_form_number()
        if active_form_number is not None:            
            all_forms_classes = self.get_forms_classes()
            active_form_class = all_forms_classes[active_form_number]
            return active_form_class(**self.get_form_kwargs(is_active=True))

    def get_form_kwargs(self, is_active=False):
        """
        Returns the keyword arguments for instantiating the form.
        """
        kwargs = {
            'initial': self.get_initial(),
            'prefix': self.get_prefix(),
        }

        if is_active:
            kwargs.update({
                'data': self.request.POST,
                'files': self.request.FILES,
            })
        return kwargs

    def get_success_url(self):
        """
        Returns the supplied success URL.
        """
        if self.success_url:
            # Forcing possible reverse_lazy evaluation
            url = force_text(self.success_url)
        else:
            raise ImproperlyConfigured(
                "No URL to redirect to. Provide a success_url.")
        return url

    def form_valid(self, form):
        """
        If the form is valid, redirect to the supplied URL.
        """
        return HttpResponseRedirect(self.get_success_url())

    def form_invalid(self, form):
        """
        If the form is invalid, re-render the context data with the
        data-filled forms and errors.
        """
        return self.render_to_response(self.get_context_data(active_form=form))

    def get_context_data(self, **kwargs):
        """
        Insert the forms into the context dict.
        """
        if 'forms' not in kwargs:
            kwargs['forms'] = self.get_forms(kwargs.get('active_form'))
        return super(MultipleFormsMixin, self).get_context_data(**kwargs)

Ready to use, base view (notice we're using here standard CBV components for working with forms - our solution is fully compatible!):

from django.views.generic.base import TemplateResponseMixin
from django.views.generic.edit import ProcessFormView

class MultipleFormsView(TemplateResponseMixin, MultipleFormsMixin, ProcessFormView):
    pass

How it works? Basically, it's a modified code of FormMixin from Django core. We have a couple of additional methods, like get_forms_classes, get_active_form_number etc. View takes a list of forms, instantiates them and adds to template context.

We need to add this input to forms in template (you should render forms in a loop):

<input type="hidden" name="selected_form" value="{{ forloop.counter0 }}">

Thanks to that, we know which form was submitted (look at get_active_form_number method). Then, we just take proper form, checks if it's valid and run form_valid or form_invalid method (it's actually inside ProcessFormView).

That's it! Hope it will be useful for you. If yes, tweet about this!

Author image
Warsaw, Poland
Full Stack geek. Likes Docker, Python, and JavaScript, always interested in trying new stuff.