15

I am trying to adapt an approach for saving nested formsets with main form using Django-Crispy-Forms layout feature but I can't save it. I am following this code example project but couldn't get formset validated to save data. I will be really thankful if someone can point out my mistake. I also need to add three inlines in same view for EmployeeForm. I tried Django-Extra-Views but couldn't make that work. Would appreciate if you advise for adding more than one inlines for same view like around 5. All I want to achieve that a single page for creating Employee and its inlines like Education, Experience, Others. Below is the code:

models:

class Employee(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='employees',
                                null=True, blank=True)
    about = models.TextField()
    street = models.CharField(max_length=200)
    city = models.CharField(max_length=200)
    country = models.CharField(max_length=200)
    cell_phone = models.PositiveIntegerField()
    landline = models.PositiveIntegerField()

    def __str__(self):
        return '{} {}'.format(self.id, self.user)

    def get_absolute_url(self):
        return reverse('bars:create', kwargs={'pk':self.pk})

class Education(models.Model):
    employee = models.ForeignKey('Employee', on_delete=models.CASCADE, related_name='education')
    course_title = models.CharField(max_length=100, null=True, blank=True)
    institute_name = models.CharField(max_length=200, null=True, blank=True)
    start_year = models.DateTimeField(null=True, blank=True)
    end_year = models.DateTimeField(null=True, blank=True)

    def __str__(self):
        return '{} {}'.format(self.employee, self.course_title)

View:

class EmployeeCreateView(CreateView):
    model = Employee
    template_name = 'bars/crt.html'
    form_class = EmployeeForm
    success_url = None

    def get_context_data(self, **kwargs):
        data = super(EmployeeCreateView, self).get_context_data(**kwargs)
        if self.request.POST:
            data['education'] = EducationFormset(self.request.POST)
        else:
            data['education'] = EducationFormset()
        print('This is context data {}'.format(data))
        return data


    def form_valid(self, form):
        context = self.get_context_data()
        education = context['education']
        print('This is Education {}'.format(education))
        with transaction.atomic():
            form.instance.employee.user = self.request.user
            self.object = form.save()
            if education.is_valid():
                education.save(commit=False)
                education.instance = self.object
                education.save()

        return super(EmployeeCreateView, self).form_valid(form)

    def get_success_url(self):
        return reverse_lazy('bars:detail', kwargs={'pk':self.object.pk})

Forms:

class EducationForm(forms.ModelForm):
    class Meta:
        model = Education
        exclude = ()
EducationFormset =inlineformset_factory(
    Employee, Education, form=EducationForm,
    fields=['course_title', 'institute_name'], extra=1,can_delete=True
    )

class EmployeeForm(forms.ModelForm):

    class Meta:
        model = Employee
        exclude = ('user', 'role')

    def __init__(self, *args, **kwargs):
        super(EmployeeForm, self).__init__(*args, **kwargs)
        self.helper = FormHelper()
        self.helper.form_tag = True
        self.helper.form_class = 'form-horizontal'
        self.helper.label_class = 'col-md-3 create-label'
        self.helper.field_class = 'col-md-9'
        self.helper.layout = Layout(
            Div(
                Field('about'),
                Field('street'),
                Field('city'),
                Field('cell_phone'),
                Field('landline'),
                Fieldset('Add Education',
                    Formset('education')),
                HTML("<br>"),
                ButtonHolder(Submit('submit', 'save')),
                )
            )

Custom Layout Object as per example:

from crispy_forms.layout import LayoutObject, TEMPLATE_PACK
from django.shortcuts import render
from django.template.loader import render_to_string

class Formset(LayoutObject):
    template = "bars/formset.html"

    def __init__(self, formset_name_in_context, template=None):
        self.formset_name_in_context = formset_name_in_context
        self.fields = []
        if template:
            self.template = template

    def render(self, form, form_style, context, template_pack=TEMPLATE_PACK):
        formset = context[self.formset_name_in_context]
        return render_to_string(self.template, {'formset': formset})

Formset.html:

{% load static %}
{% load crispy_forms_tags %}
{% load staticfiles %}

<table>
{{ formset.management_form|crispy }}

    {% for form in formset.forms %}
            <tr class="{% cycle 'row1' 'row2' %} formset_row-{{ formset.prefix }}">
                {% for field in form.visible_fields %}
                <td>
                    {# Include the hidden fields in the form #}
                    {% if forloop.first %}
                        {% for hidden in form.hidden_fields %}
                            {{ hidden }}
                        {% endfor %}
                    {% endif %}
                    {{ field.errors.as_ul }}
                    {{ field|as_crispy_field }}
                </td>
                {% endfor %}
            </tr>
    {% endfor %}

</table>
<br>
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js">
</script>
<script src="{% static 'js/jquery.formset.js' %}">
</script>
<script type="text/javascript">
    $('.formset_row-{{ formset.prefix }}').formset({
        addText: 'add another',
        deleteText: 'remove',
        prefix: '{{ formset.prefix }}',
    });
</script>

There are no errors in terminal and or otherwise. Help is much appreciated.

Shazia Nusrat
  • 174
  • 1
  • 10
  • 36
  • An alternative solution is to have the form handle the formset too: I do it using a cached_property for the related formset in https://schinckel.net/2019/05/23/form-and-formset/ – Matthew Schinckel Mar 11 '20 at 09:58

3 Answers3

2

You aren't currently processing the formset properly in your CreateView. form_valid in that view will only handle the parent form, not the formsets. What you should do is override the post method, and there you need to validate both the form and any formsets that are attached to it:

def post(self, request, *args, **kwargs):
    form = self.get_form()
    # Add as many formsets here as you want
    education_formset = EducationFormset(request.POST)
    # Now validate both the form and any formsets
    if form.is_valid() and education_formset.is_valid():
        # Note - we are passing the education_formset to form_valid. If you had more formsets
        # you would pass these as well.
        return self.form_valid(form, education_formset)
    else:
        return self.form_invalid(form)

Then you modify form_valid like so:

def form_valid(self, form, education_formset):
    with transaction.atomic():
        form.instance.employee.user = self.request.user
        self.object = form.save()
        # Now we process the education formset
        educations = education_formset.save(commit=False)
        for education in educations:
            education.instance = self.object
            education.save()
        # If you had more formsets, you would accept additional arguments and
        # process them as with the one above.
    # Don't call the super() method here - you will end up saving the form twice. Instead handle the redirect yourself.
    return HttpResponseRedirect(self.get_success_url())

They way you are currently using get_context_data() isn't correct - remove that method completely. It should only be used to fetch context data for rendering a template. You shouldn't call it from your form_valid() method. Instead you need to pass the formset to this method from the post() method as outlined above.

I've left a few additional comments in the sample code above which will hopefully help you figure this out.

solarissmoke
  • 30,039
  • 14
  • 71
  • 73
  • Please re-create an example locally before you answer. I've tried your piece but not working. – Shazia Nusrat Feb 26 '20 at 08:53
  • 4
    @ShaziaNusrat sorry, I don't have time to try and work out what isn't working for you especially if you don't say what you've tried and what didn't work ("It's not working" isn't an adequate description of what didn't work). I believe there is enough in my answer to help you identify what you need to change with your current implementation. If not, then let's hope someone else will be able to give you a more comprehensive answer. – solarissmoke Feb 26 '20 at 09:04
  • I tried it in code for testing and it ran with problems. That's why I humbly request you to try it on your side locally so you can guide me better. I am thankful as you took some time to help me. But not working. – Shazia Nusrat Feb 26 '20 at 09:13
2

Maybe you would like to see the package django-extra-views, the provides the view CreateWithInlinesView, witch allows you to create form with nested inlines like Django-admin inlines.

In your case, it would be something like that:

views.py

class EducationInline(InlineFormSetFactory):
    model = Education
    fields = ['course_title', 'institute_name']


class EmployeeCreateView(CreateWithInlinesView):
    model = Employee
    inlines = [EducationInline,]
    fields = ['about', 'street', 'city', 'cell_phone', 'landline']
    template_name = 'bars/crt.html'

crt.html

<form method="post">
  ...
  {{ form }}
  <table>
  {% for formset in inlines %}
    {{ formset.management_form }}
      {% for inline_form in formset %}
        <tr class="{% cycle 'row1' 'row2' %} formset_row-{{ formset.prefix }}">
          {{ inline_form }}
        </tr>
      {% endfor %}
  {% endfor %}
  </table>
  ...
  <input type="submit" value="Submit" />
</form>

<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js">
</script>
<script src="{% static 'js/jquery.formset.js' %}">
</script>
<script type="text/javascript">
    {% for formset in inlines %}
      $('.formset_row-{{ formset.prefix }}').formset({
          addText: 'add another',
          deleteText: 'remove',
          prefix: '{{ formset.prefix }}',
      });
    {% endfor %}
</script>

The view EmployeeCreateView will processing the forms for you as in Django-admin. From this point you can apply the style you want to the forms.

I recommend you visit the documentation for more information

EDITED: I added management_form and the js buttons to add/remove.

John
  • 770
  • 1
  • 9
  • 18
  • I tried that already but that won't let me have add/delete buttons for multiple inlines. It only support one inline with JS buttons. I've tried that already. – Shazia Nusrat Mar 04 '20 at 13:46
  • 2
    It supports it, you have to add the `management_form` for each `formset` – John Mar 04 '20 at 14:02
0

You said that there is an error but you are not showing it in your question. The error (and the whole traceback) is more important than anything you wrote (except may be from forms.py and views.py)

Your case is a little bit trickier because of the formsets and using multiple forms on the same CreateView. There are not many (or not many good ) examples on the internet. Untill you dig in the django code how inline formsets are working you will have troubles.

Ok straight to the point. Your problem is that formsets are not initialized with the same instance as your main form. And when your amin form saves the data to the database the instance in the formset is not changed and at the end you do not have the ID of the main object in order to be put as foreign key. Changing the instance attribute of a form attribute after init is not a good idea.

In normal forms if you chnage it after is_valid you will have unpredictable results. For formsets changing instance attribute even directly after init will do nothig, becase the forms in the formset are already initialized with some instance, and changing it after will not help. The good news is that you can change attributes of the instance after Formset is initialized, because all forms's instance attributes will point to the same object after formset is initialized.

You have two options:

Instead of setting instance attribute if the formset, set only the instance.pk. (This just a guessing I have never do it but I think it should work. The problem is that it will look as hack). Create a form that will initialize all forms/formsets at once. When it's is_valid() method is called all fomrs should be validated. When it's save() method is called all forms must be saved. Then you need to set form_class attribute of your CreateView to that form class. The only tricky part is that after your main form is initialized you need to initialize the others (formsests) with the instance of your first form. Also you need to set the forms/formsets as attributes of your form in order to have access to them in the template. I'm using the second approach when I need to create an object with all it's related objects. This will separate business logic from view logic because from the view perspective you just have a form which can be:

initialized with some data (in this case POST data) checked for validity with is_valid() can be saved with save() when it's valid. You preserve the form interface and if you made your form correctly you can even use it not only for creating but for updating objects together with their related objects and the views will be very simple.