Cheng
Cheng

Reputation: 17904

Django remove inline formset record in UpdateView

I have an UpdateView which contains a form and an InlineFormetSet that is related to the form model (I simplified the code below):

#models.py
class Note(Model):
    content = models.TextField()

class Dialog(Model):
    message = models.TextField()
    note = modes.ForeignKey(Note)


#views.py
class NoteUpdateView(UpdateView):
    model = Note
    form_class = NoteForm

    def get_context_data(self, **kwargs):
        context = super(NoteUpdateView ,self).get_context_data(**kwargs)
        self.object = self.get_object()
        dialogFormset = inlineformset_factory(Note,
                                              Dialog,
                                              fields='__all__',
                                              extra=0)
        dialog = dialogFormset(instance=self.object)
        context['dialog'] = dialog
        return context

    def post(self, request, *args, **kwargs):
        form = self.get_form(self.get_form_class())
        dialog_form = DialogFormset(self.request.POST, instance=Note.objects.get(id=self.kwargs['pk']))

        if (form.is_valid() and dialog_form.is_valid()):
            return self.form_valid(form, result_form, dialog_form)
        else:
            return self.form_invalid(form, result_form, dialog_form)

    def form_valid(self, form, result_form, dialog_form):
        self.object, created = Note.objects.update_or_create(pk=self.kwargs['pk'], defaults=form.cleaned_data)
        dialog_form.save()
        return HttpResponseRedirect(self.get_success_url())


    def form_invalid(self, form, result_form, dialog_form):
        return self.render_to_response(self.get_context_data(form=form,
                                                             result_form=result_form,
                                                             dialog_form=dialog_form))

The purpose of NoteUpdateView is to render existing Note and Dialog when a GET request is made tonote/11. A user may delete an existing Dialog, which is not handled by the code above.

To handle delete, I can do the following on POST:

1) fetch all of the dialog records related to the requested Note: dialogs = Note.objects.filter(pk=self.kwargs['pk'])

2) loop through self.request.POST and see if the formsets contained in the submitted data also exist in the dialogs created above.

3) If a record is dialogs but not submitted via POST, then that dialog is considered to be removed by the user. Thus, preform delete operation.

I am sure I can implement these steps. But since I am not very familiar with Django's formset. I wonder if there is any built-in classes or methods that I should use to automate these steps. What is the Django way of doing what I just described?

Upvotes: 2

Views: 1778

Answers (2)

Cheng
Cheng

Reputation: 17904

Ok, I figured out what the problem was. The problem that I run into is due to the use of django-crispy-forms. Let me explain what happened:

When Django renders InlineFormSet, it's can_delete attribute is set to True automatically. When this attribute is set to True, a hidden input field is inserted into the rendered HTML:

<input type="hidden" name="dialog_set-0-DELETE" id="id_dialog_set-0-DELETE">

I used django-crispy-forms to render my forms so that they are styled with bootstrap3. When rendering inlineformset using crispy-forms, a FormHelper needs to be defined.

This is because when you have multiple inlineformset forms on the page, you will only want one <form> tag surrounds them instead of giving each inlineformset form it's own <form> tag. To do that, I had to define the FormHelper like this:

#models.py
class Dialog(Model):
    info1 = models.TextField()
    info2 = models.TextField()

#forms.py
class DialogFormSetHelper(FormHelper):
 def __init__(self, *args, **kwargs):
    super(DialogFormSetHelper, self).__init__(*args, **kwargs)
    self.form_tag = False    # This line removes the '<form>' tag
    self.disable_csrf = True # No need to insert the CSRF string with each inlineform
    self.layout = Layout(
        Field('info1', rows='3'), # make sure the name of the field matches the names defined in the corresponding model
        Field('info2', rows='3') 
    )

I need django-crispy-forms to set the row number of a textarea tag to be 3. Thus, I had to specifically redefine how my textarea fields look like under Layout. What I didn't realize when using the Layout is that anything that you didn't define in it will not be rendered in the HTML.

From the look of the code, I didn't miss any fields defined in the Dialog model. But, what I didn't realize is that the hidden fields that come with InlineFormSet are not rendered in the HTML unless I specifically define them in the Layout object and in the template. To get formset & inlineformset working properly, you will need the following hidden inputs:

  1. formset.manageform. They look like this in the HTML:

    <input id="id_dialog_set-TOTAL_FORMS" name="dialog_set-TOTAL_FORMS" type="hidden" value="1">
    <input id="id_dialog_set-INITIAL_FORMS" name="dialog_set-INITIAL_FORMS" type="hidden" value="1">
    <input id="id_dialog_set-MIN_NUM_FORMS" name="dialog_set-MIN_NUM_FORMS" type="hidden" value="0">
    <input id="id_dialog_set-MAX_NUM_FORMS" name="dialog_set-MAX_NUM_FORMS" type="hidden" value="1000">
    
  2. The primary key that is associated with each inlineformset form, and a foreign key that the inlineformset refers to. They look like this in HTML:

    <input id="id_dialog_set-0-note" name="dialog_set-0-note" type="hidden" value="11">  <!-- This line identifies the foreign key`s id -->
    <input id="id_dialog_set-0-id" name="dialog_set-0-id" type="hidden" value="4"> <!-- This line identifies the inlineformset form`s id -->
    
  3. [A DELETE hidden field when can_delete is set to True] (https://docs.djangoproject.com/en/1.9/topics/forms/formsets/#can-delete). It looks like this in the HTML:

    <input type="hidden" name="dialog_set-0-DELETE" id="id_dialog_set-0-DELETE"> 
    

In my template, I had the first two covered:

<form method="post" action="{{ action }}" enctype="multipart/form-data" id="note_form">
    {% crispy form %}

    {# the management_form is covered here #}
    {{ dialog.management_form }}

    {% for form in dialog %}
        <div class="formset-container">
            <div class="dialog-title">
                {% crispy form dialogHelper %}
            </div>

            {# the hidden fields are covered here #}
            {% for hidden in form.hidden_fields %}
                {{ hidden }}
            {% endfor %}

         </div>
    {% endfor %}
</form>

What I didn't have is the DELETE hidden input. To add it to the HTML, I had to add it this way in the Layout:

#forms.py
class DialogFormSetHelper(FormHelper):
 def __init__(self, *args, **kwargs):
    super(DialogFormSetHelper, self).__init__(*args, **kwargs)
    self.form_tag = False    
    self.disable_csrf = True 
    self.layout = Layout(
        Field('info1', rows='3'), 
        Field('info2', rows='3'),
        Field('DELETE')  # <- ADD THIS LINE
    )

Finally, everything works properly now

Upvotes: 3

Tomas Walch
Tomas Walch

Reputation: 2305

The Django way is to check if someone has made a library that handles this for you :-).

So take a look at the exellent django-extra-views and it's InlineFormSetView. I've used it a lot and it works really well. In your case your view becomes something like this:

from extra_views import InlineFormSetView

class NoteUpdateView(InlineFormSetView):
    model = Note
    inline_model = Dialog
    form_class = NoteForm
    extra = 0

    def get_context_data(self, **kwargs):
        context = super(NoteUpdateView ,self).get_context_data(**kwargs)
        context['dialog'] = context['formset']
        return context

You could skip .get_context_data method as well if you update your template to refer to the formset as "formset" instead.

Upvotes: 1

Related Questions