django-viewsdjango-formsformset

Django CreateView combine modelformset and inlineformset


I have two forms in my CreateView:

  1. RequisitionModelForm (MaterialRequisition model) -> this saves
  2. RequisitionItemsModelForm (MaterialRequisitionItems model) --> this does not save

The second form can have multiple entries to be saved into their own MaterialRequisitionItems object.

My goal is to save multiple item objects under one MaterialRequisition object.

Here is a screenshot of my template:enter image description here

I used inlineformset_factory to display both forms in the same view, but I am not sure how to go about creating another formset for the second form that creates multiple item objects. I imagine I would need formset_factory for the saving of multiple item objects.

forms.py

RequisitionInlineFormSet = forms.inlineformset_factory(
    MaterialRequisition,
    MaterialRequisitionItems,
    form = RequisitionItemsModelForm,
    extra=1,
    can_delete=False,
    can_order=False,
    )

If someone could point me to a general direction, it would be greatly appreciated. :)


Other code for reference

views.py

class RequisitionAddView(LoginRequiredMixin, generic.CreateView):
    template_name = "requisition/requisition_add.html"
    form_class = RequisitionModelForm
    model = MaterialRequisition
    
    def form_valid(self, form):
        print(form.data)
        ctx = self.get_context_data()
        inlines = ctx['inlines']
        
        if inlines.is_valid() and form.is_valid():
            req = form.save(commit = False)
            req.site = self.request.user.site
            req.save()
            for form in inlines:
                form.save()
        return super(RequisitionAddView, self).form_valid(form)

    def get_context_data(self, **kwargs):
        ctx=super(RequisitionAddView,self).get_context_data(**kwargs)
        ctx['item_list'] = ReqItemModelForm()
        if self.request.POST:
            ctx['form']=RequisitionModelForm(self.request.POST)
            ctx['inlines']=RequisitionInlineFormSet(self.request.POST)
        else:
            ctx['form']=RequisitionModelForm()
            ctx['inlines']=RequisitionInlineFormSet()
        return ctx

forms.py

class RequisitionModelForm(forms.ModelForm):
    class Meta:
        model = MaterialRequisition
        fields = (
            'reqDescription',
            'reqDateNeeded',
        )

class RequisitionItemsModelForm(forms.ModelForm):
    class Meta:
        model = MaterialRequisitionItems
        fields = (
            'requisition',
            'item',
            'itemQuantity',
        )

requisition_add.html

<form
          id="reqForm"
          class="form-inline"
          method="post"
          action=""
          {% csrf_token %}
          {{ form.reqDescription|as_crispy_field }}
          {{ form.reqDateNeeded|as_crispy_field }}

          {{ inlines.management_form }}
          <div class="flex mb-5">
            <a
              class="flex-grow text-indigo-500 border-b-2 border-indigo-500 py-2 text-lg"
              >Items Requested</a
            >
          </div>
          <div id="form_set">
            {% for form in inlines.forms %}
            <div class="flex flex-row">
                <div class='w-full grid grid-cols-2 gap-5 no_error'>
                    {{ form|crispy }}
                </div>
                <input class="delete cursor-pointer ml-5 my-auto text-center text-gray-900 cursor-pointer text-sm font-medium h-full [TEST] text-white bg-rose-500 hover:bg-rose-600 ease-in duration-100 border-0 py-1 px-3 focus:outline-none rounded-full" value="Delete" type="button" formnovalidate></input>
          </div>
            {% endfor %}
          </div>
          
          <input class="flex mt-3 mx-auto text-center text-gray-900 cursor-pointer text-sm font-medium h-full [TEST] text-white bg-slate-500 hover:bg-slate-600 ease-in duration-100 border-0 py-2 px-5 focus:outline-none rounded-full" type="button" value="Add an Item +" id="add_more">

          <div id="empty_form" style="display:none">
            <div class="flex flex-row">
                <div class='w-full grid grid-cols-2 gap-5 no_error'>
                    {{ inlines.empty_form|crispy }}
                    
                </div>
                <input class="delete cursor-pointer ml-5 my-auto text-center text-gray-900 cursor-pointer text-sm font-medium h-full [TEST] text-white bg-rose-500 hover:bg-rose-600 ease-in duration-100 border-0 py-1 px-3 focus:outline-none rounded-full" value="Delete" type="button" formnovalidate></input>
          </div>
        </div>

          <div class="flex justify-end mt-6 ml-auto">
            <div id="buttons" class="flex">
              <input
                type="submit"
                value="Submit"
                class="flex ml-3 text-white bg-indigo-500 cursor-pointer border-0 py-2 px-6 focus:outline-none hover:bg-indigo-600 rounded ease-in duration-100"
              />
              <a href="{% url 'requisition:list-requisition' %}" class="flex ml-3 text-black bg-gray-200 border-0 py-2 px-6 focus:outline-none hover:bg-gray-300 rounded ease-in duration-100">
                Cancel
              </a>
            </div>
          </div>
        </form>
        
      </div>
    </div>
    <script>
      $('#add_more').click(function() {
          var form_idx = $('#id_form-TOTAL_FORMS').val();
          $('#form_set').append($('#empty_form').html().replace(/__prefix__/g, form_idx));
          $('#id_form-TOTAL_FORMS').val(parseInt(form_idx) + 1);
      });

      $(document).on("click", ".delete", function() {
        $(this).parent().remove(); 
});
  </script>

JS for adding/removing fields

$('#add_more').click(function(ev) {
          // var form_idx = $('#id_form-TOTAL_FORMS').val();
          // $('#form_set').append($('#empty_form').html().replace(/__prefix__/g, form_idx));
          // $('#id_form-TOTAL_FORMS').val(parseInt(form_idx) + 1);
        ev.preventDefault();
        var count = $('#form_set').children().length;
        var tmplMarkup = $('#empty_form').html();
        var compiledTmpl = tmplMarkup.replace(/__prefix__/g, count);
        $('div#form_set').append(compiledTmpl);
        $('#id_materialrequisitionitems_set-TOTAL_FORMS').attr('value', count+1);
      });

      $(document).on("click", ".delete", function() {
        var delcount = $('#form_set').children().length;
        $('#id_materialrequisitionitems_set-TOTAL_FORMS').attr('value', delcount-1);
        $(this).parent().remove();
      });

Solution

  • What I normally do in this cases is to embed the inline forms in the main form so when the main form saves the inline forms are saved as well.

    Problem: To point to the problem of your code, it seems that you're not setting the instance when saving the inline formset. Also, you're saving the forms in the formset one by one. This is not necessary as the formset class has save method which does it for you. Finally, since you're rendering the inline forms with crispy-forms, they automatically add a form tag to your rendered forms. Since the <form> tag in HTML cannot have children forms, this is probably the actual cause why your forms are not saving. You can avoid this by configuring the crispy forms helper.

    Let me present you with two solutions and you let me know if they work.

    Solution #1 - patching your existing code:

    def form_valid(self, form):
            print(form.data)
            ctx = self.get_context_data()
            inlines = ctx['inlines']
            
            if inlines.is_valid() and form.is_valid():
                # Better to do this like this
                # See https://www.django-antipatterns.com/antipattern/using-commit-false-when-altering-the-instance-in-a-modelform.html           
                form.instance.site = self.request.user.site
                req = form.save()
    
                # Setting the instance so the formset knows how to set the FK
                # and saving the formset in one line
                inlines.instance = req
                inlines.save()
    
            return super(RequisitionAddView, self).form_valid(form)
    

    Configure the helper:

    class RequisitionItemsModelForm(forms.ModelForm):
        class Meta:
            model = MaterialRequisitionItems
            fields = (
                'requisition',
                'item',
                'itemQuantity',
            )
    
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
    
            self.helper = FormHelper()  # imported from crispy_forms.helper
            self.form_tag = False  # solves the form tag problem
    

    To be safe, this should be probably done in the other model form as well.

    Solution #2 - embedding the inline formset in your RequisitionModelForm:

    class RequisitionModelForm(forms.ModelForm):
        class Meta:
            model = MaterialRequisition
            fields = (
                'reqDescription',
                'reqDateNeeded',
            )
    
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
    
            self.requisition_formset = RequisitionInlineFormSet(
                data=kwargs.get('data'), instance=self.instance
            )
    
            self.helper = FormHelper()
            self.form_tag = False
    
        # Now you need to override save to save the inline formset, too
        def save(self, **kwargs):
            # If any operations fail, we rollback
            with transaction.atomic():
                # Saving the MaterialRequisition first
                saved_req = super().save(**kwargs)
    
                # Saving the inline formsets
                self.requisition_formset.instance = saved_req
                self.requisition_formset.save()
    
                return saved_req
    
        # Also needs to be overridden in case any clean method are implemented
        def clean(self):
            self.requisition_formset.clean()
            super().clean()
    
            return self.cleaned_data
    
        # is_valid sets the cleaned_data attribute so we need to override that too
        def is_valid(self):
            is_valid = True
            is_valid &= self.requisition_formset.is_valid()
            is_valid &= super().is_valid()
    
            return is_valid
    
         # In case you're using the form for updating, you need to do this too
         # because nothing will be saved if you only update field in the inner
         # formset
         def has_changed(self):
             has_changed = False
    
             has_changed |= self.requisition_formset.has_changed()
             has_changed |= super().has_changed()
    
             return has_changed
    

    So, this solution is a bit longer but keeps the form logic in the form code so I consider it cleaner. Now in the view code you can simply do this:

    class RequisitionAddView(LoginRequiredMixin, generic.CreateView):
        template_name = "requisition/requisition_add.html"
        form_class = RequisitionModelForm
        model = MaterialRequisition
    
        # No need for form_valid or get_context_data!
    

    PS. another clean code tip: don't use if self.request.POST for checking if the request is a POST request. A better practice is to use if self.request.method == 'POST'. See this for more details.