multi step form and model inheritance in django

Viewed 367

I have seen this approach in many web applications (e.g. when you subscribe for an insurance), but I can't find a good way to implement it in django. I have several classes in my model which inherit from a base class, and so they have several fields in common. In the create-view I want to use that inheritance, so first ask for the common fields and then ask for the specific fields, depending on the choices of the user.

Naive example, suppose I want to fill a database of places

class Place(Model):
    name = models.CharField(max_length=40)
    address = models.CharField(max_length=100)

class Restaurant(Place):
    cuisine = models.CharField(max_length=40)
    website = models.CharField(max_length=40)

class SportField(Place):
    sport = models.CharField(max_length=40)

Now I would like to have a create view when there are the common fields (name and address) and then the possibility to choose the type of place (Restaurant / SportField). Once the kind of place is selected (or the user press a "Continue" button) new fields appear (I guess to make it simple the page need to reload) and the old one are still visible, already filled.

I have seen this approach many times, so I am surprised there is no standard way, or some extensions already helping with that (I have looked at Form Wizard from django-formtools, but not really linked to inheritance), also doing more complicated stuff, as having more depth in inheritance.

6 Answers

models.py

class Place(models.Model):
    name = models.CharField(max_length=40)
    address = models.CharField(max_length=100)

    
class Restaurant(Place):
    cuisine = models.CharField(max_length=40)
    website = models.CharField(max_length=40)


class SportField(Place):
    sport = models.CharField(max_length=40)

forms.py

from django.db import models
from django import forms

class CustomForm(forms.Form):
    CHOICES = (('restaurant', 'Restaurant'), ('sport', 'Sport'),)
    name = forms.CharField(widget=forms.TextInput(attrs={'placeholder': 'Name'}))
    address = forms.CharField(widget=forms.TextInput(attrs={'placeholder': 'Address'}))
    type = forms.ChoiceField(
        choices=CHOICES,
        widget=forms.Select(attrs={'onChange':'renderForm();'}))
    cuisine = forms.CharField(required=False, widget=forms.TextInput(attrs={'placeholder': 'Cuisine'}))
    website = forms.CharField(required=False, widget=forms.TextInput(attrs={'placeholder': 'Website'}))
    sport = forms.CharField(required=False, widget=forms.TextInput(attrs={'placeholder': 'Sport'}))

views.py

from django.http.response import HttpResponse
from .models import Restaurant, SportField
from .forms import CustomForm
from django.shortcuts import render
from django.views import View


class CustomView(View):

    def get(self, request,):
        form = CustomForm()
        return render(request, 'home.html', {'form':form})

    def post(self, request,):
        data = request.POST
        name = data['name']
        address = data['address']
        type = data['type']
        if(type == 'restaurant'):
            website = data['website']
            cuisine = data['cuisine']
            Restaurant.objects.create(
                name=name, address=address, website=website, cuisine=cuisine
            )
        else:
            sport = data['sport']
            SportField.objects.create(name=name, address=address, sport=sport)
        return HttpResponse("Success")

templates/home.html

<html>

<head>
    <script type="text/javascript">
        function renderForm() {
            var type =
                document.getElementById("{{form.type.auto_id}}").value;
            if (type == 'restaurant') {
                document.getElementById("{{form.website.auto_id}}").style.display = 'block';
                document.getElementById("{{form.cuisine.auto_id}}").style.display = 'block';
                document.getElementById("{{form.sport.auto_id}}").style.display = 'none';
            } else {
                document.getElementById("{{form.website.auto_id}}").style.display = 'none';
                document.getElementById("{{form.cuisine.auto_id}}").style.display = 'none';
                document.getElementById("{{form.sport.auto_id}}").style.display = 'block';
            }

        }
    </script>
</head>

<body onload="renderForm()">
    <form method="post" action="/">
        {% csrf_token %}
        {{form.name}}<br>
        {{form.address}}<br>
        {{form.type}}<br>
        {{form.website}}
        {{form.cuisine}}
        {{form.sport}}
        <input type="submit">
    </form>
</body>

</html>

Add templates folder in settings.py

TEMPLATES = [
    {
        ...
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        ...
]

I've created a 2-page working example using modified Class Based Views.

When the form is submitted on the first page, an object of place_type is created. The user is then redirected to the second page where they can update existing details and add additional information.

No separate ModelForms are needed because the CreateView and UpdateView automatically generate the forms from the relevant object's model class.

A single template named place_form.html is required. It should render the {{ form }} tag.

# models.py
from django.db import models
from django.urls import reverse

class Place(models.Model):
    """
    Each tuple in TYPE_CHOICES contains a child class name
    as the first element.

    """
    TYPE_CHOICES = (
        ('Restaurant', 'Restaurant'),
        ('SportField', 'Sport Field'),
    )
    name = models.CharField(max_length=40)
    address = models.CharField(max_length=100)
    place_type = models.CharField(max_length=40, blank=True, choices=TYPE_CHOICES)

    def __str__(self):
        return self.name

    def get_absolute_url(self):
        return reverse('place_update', args=[self.pk])

# Child models go here...
# urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('create/', views.PlaceCreateView.as_view(), name='place_create'),
    path('<pk>/', views.PlaceUpdateView.as_view(), name='place_update'),
]
# views.py
from django.http import HttpResponseRedirect
from django.forms.models import construct_instance, modelform_factory
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse_lazy

from . import models

class PlaceCreateView(CreateView):
    model = models.Place
    fields = '__all__'

    def form_valid(self, form):
        """
        If a `place_type` is selected, it is used to create an 
        instance of that Model and return the url.

        """
        place_type = form.cleaned_data['place_type']
        if place_type:
            klass = getattr(models, place_type)
            instance = klass()
            obj = construct_instance(form, instance)
            obj.save()
            return HttpResponseRedirect(obj.get_absolute_url())
        return super().form_valid(form)

class PlaceUpdateView(UpdateView):
    fields = '__all__'
    success_url = reverse_lazy('place_create')
    template_name = 'place_form.html'

    def get_object(self, queryset=None):
        """
        If the place has a `place_type`, get that object instead.

        """
        pk = self.kwargs.get(self.pk_url_kwarg)
        if pk is not None:
            obj = models.Place.objects.get(pk=pk)
            if obj.place_type:
                klass = getattr(models, obj.place_type)
                obj = klass.objects.get(pk=pk)
        else:
            raise AttributeError(
                "PlaceUpdateView must be called with an object pk in the URLconf."
            )
        return obj

    def get_form_class(self):
        """
        Remove the `place_type` field.

        """
        model = self.object.__class__
        return modelform_factory(model, exclude=['place_type',])

We did something similar manually, we created the views and forms based on design and did the linkage based on if conditions.

I think a nice solution would be to dynamically access subclasses of the main class and then do the necessary filtering/lists building.

UPD: I've spent some more time today on this question and made a "less raw" solution that allows to use the inheritance.

You can also check the code below deployed here. It has only one level of inheritance (as in example), though, the approach is generic enough to have multiple levels

views.py

def inheritance_view(request):
    all_forms = {form.Meta.model: form for form in forms.PlaceForm.__subclasses__()}
    all_forms[models.Place] = forms.PlaceForm

    places = {cls._meta.verbose_name: cls for cls in models.Place.__subclasses__()}

    # initiate forms with the first one
    context = {
        'forms': [forms.PlaceForm(request.POST)],
    }

    # check sub-forms selected on the forms and include their sub-forms (if any)
    for f in context['forms']:
        f.sub_selected = request.POST.get('{}_sub_selected'.format(f.Meta.model._meta.model_name))
        if f.sub_selected:
            sub_form = all_forms.get(places.get(f.sub_selected))
            if sub_form not in context['forms']:
                context['forms'].append(sub_form(request.POST))

    # update some fields on forms to render them on the template
    for f in context['forms']:
        f.model_name = f.Meta.model._meta.model_name
        f.sub_forms = {x.Meta.model._meta.verbose_name: x for x in f.__class__.__subclasses__()}
        f.sub_options = f.sub_forms.keys()    # this is for rendering selector on the form for the follow-up forms

    page = loader.get_template(template)
    response = HttpResponse(page.render(context, request))
    return response

    

forms.py

class PlaceForm(forms.ModelForm):
    class Meta:
        model = models.Place
        fields = ('name', 'address',)

class RestaurantForm(PlaceForm):
    class Meta:
        model = models.Restaurant
        fields = ('cuisine', 'website',)

class SportFieldForm(PlaceForm):
    class Meta:
        model = models.SportField
        fields = ('sport',)

templates/inheritance.html

<body>
  {% for form in forms %}
    <form method="post">
    {% csrf_token %}
      {{ form.as_p }}
      {% if form.sub_options %}
      <select class="change-place" name="{{ form.model_name }}_sub_selected">
          {% for option in form.sub_options %}
          <option value="{{ option }}" {% if option == form.sub_selected %}selected{% endif %}>{{ option }}</option>
          {% endfor %}
      </select>
      {% endif %}
    <button type="submit">Next</button>
  </form>
  {% endfor %}
</body>

What I didn't make here is saving the form to the database. But it should be rather trivial using the similar snippet:

for f in context['forms']:
    if f.is_valid():
        f.save()

There are lot of way you can handle multiple froms in django. The easiest way to use inlineformset_factory.

in your froms.py:

forms .models import your model
    
class ParentFrom(froms.From):
          # add fields from your parent model 
        
Restaurant = inlineformset_factory(your parent model name,Your Child model name,fields=('cuisine',# add fields from your child model),extra=1,can_delete=False,)

SportField = inlineformset_factory(your parent model name,Your Child model name,fields=('sport',# add fields from your child model),extra=1,can_delete=False,)

in your views.py

if ParentFrom.is_valid():
            ParentFrom = ParentFrom.save(commit=False)
            Restaurant = Restaurant(request.POST, request.FILES,) #if you want to add images or files then use request.FILES.
            SportField =  SportField(request.POST)

            if   Restaurant.is_valid() and SportField.is_valid():
                ParentFrom.save()
                Restaurant.save()
                SportField.save()
                return HttpResponseRedirect(#your redirect url)

#html

<form method="POST"  enctype="multipart/form-data"> 
       
        {% csrf_token %}
        #{{ Restaurant.errors}} #if you want to show error
        {{ Restaurant}}
        {{ SportField}}
        {{form}}
</form>  

you can use simple JavaScript in your html for hide and show your any froms fields

Add a PlaceType table, and a FK, e.g. type_of_place, to the Place table:

    class PlaceType(Model):
        types = models.CharField(max_length=40) # sportsfield, restaurants, bodega, etc. 
    
    class Place(Model):
        name = models.CharField(max_length=40)
        address = models.CharField(max_length=100)
        type_of_place = models.ForeignKey('PlaceType', on_delete=models.SET_NULL, null=True)

    class Restaurant(Place):
        cuisine = models.CharField(max_length=40)
        website = models.CharField(max_length=40)

This allows you to create a new Place as either SportsField, restaurant or some other type which you can easily add in the future.

When a new place is created, you'll use the standard CreateView and Model Form. Then, you can display a second form which also uses a standard CreateView that is based on the type_of_place value. These forms can be on the same page (and with javascript on the browser side, you'll hide the second form until the first one is saved) or on separate pages--which may be more practical if you intend to have lots of extra columns. The two key points are as follows:

  1. type_of_place determines which form, view, and model to use. For example, if user chooses a "Sports Field" for type_of_place, then you know to route the user off to the SportsField model form;

  2. CreateViews are designed for creating just one object/model. When
    used as intended, they are simple and easy to maintain.

Related