Friday, July 15, 2011

In django, formsets (formset_factory) allow you to list out a bunch of forms one after the other.  This is useful when you want to allow the user to add/remove objects.  For example, if you have a recipe application where the user can create new recipes, you could have an ingredient formset that allows the user to add more ingredients to a recipe.  In this case, you could also use an inline formset (inlineformset_factory) that is linked with the recipe that you're adding the ingredients for.

In my case, I had a number of questions that I wanted the user to answer.  There were a finite set of questions, and I wanted each question/answer to be listed out on a separate line on the page.  For my models I have a Question table and an Answer table.  The answers are linked to the customer's asset (don't worry about what an asset really is, it doesn't really matter) and are created at the same time the asset is created and from the same "create asset" page, so I used an inline formset to link the answers to the asset.  I thought this would be easy, but I ran into a number of problems and eventually just hand-wrote the forms.  But I'll go through the process here really for my own sake of remembering later what the limitations of inline formsets are.

models.py:
class Question(models.Model):
    question = models.CharField(max_length=250, editable=False)

    def __unicode__(self):
        return unicode(self.question)

class Answer(models.Model):
    asset = models.ForeignKey(Asset)
    question = models.ForeignKey(Question)
    answer = models.BooleanField(default=False)

    def __unicode__(self):
        return unicode('%s: %s' % (self.question, self.answer))


My tables actually had more to them, but for the sake of this example I've simplified them.

forms.py
class AnswerForm(ModelForm):
    to_save = False
    question = forms.ModelChoiceField(queryset=Question.objects.all(),
                                      widget=forms.HiddenInput())

    class Meta:
        model = Answer
        exclude = ('asset')

    def set_question(self, question):
        self.fields['answer'].label = question

views.py (there was a separate method for saving the formset, but I've unfortunately lost that code)
def get_answer_formset(asset, data):
    questions = StageQuestion.objects.all()
    extra = 0
    if not asset:
        # This is a brand new set, so we should pre-populate the questions.
        extra = len(questions)

    AnswerFormSet = inlineformset_factory(
            Asset,
            Answer,
            form=AnswerForm,
            can_delete=False,
            extra=extra,
            )

    formset = AnswerFormSet(
            data,
            prefix='que',
            instance=asset,
            )

    for form, question in zip(formset.forms, questions):
        form.set_question(question.question)
        form.initial = {'question': question.id}

    return formset

This code probably doesn't work too well anymore, since I changed it so many times, but here's a list of issues I ran into. The way a formset works is you tell it how many extra empty entries you want to display. When you have a brand new asset, extra would simply equal the number of Answers total, since I don't allow the user to actually add or remove Answers. So at first I set `extra` to equal the number of questions I wanted the user to answer, and I pre-populated each answer form with a unique question. This worked for creation, but when I wanted to edit an existing asset, it showed a list of all the questions and then a list of all the questions again, because it turned out `extra` is the number of (duh) *extra* empty questions to show. So then I set `extra` to zero, thinking that I don't want any *extra* empty questions, I just want the set of original questions, but you can't pre-define objects before putting them in the formset, so my icky solution was to set `extra` to zero only when an asset already exists and to set it to the number of questions otherwise. This worked, but it's certainly not clean.

By the way, look at that last part with the zip. This is pretty cool. I needed to loop through each form and seed it with the question. The `form.set_question` part sets the answer label so it correctly displays the question in the html. Django doesn't seem to have a label form, just a label attribute of each type of form, so I had to set the answer label to the question text and then hide the question field, otherwise the question would show up as a dropdown list of questions to select from. The `form.initial` part is my way of forcing initial data into the inline formset, since the init method doesn't allow it. This bothers me, actually, because regular formsets allow initial data, so why not inline formsets (especially since inline formsets subclass regular formsets)?

Back to the final issue. Formsets are "smart" enough to know not to add empty objects to the database, and an unchecked checkbox looks exactly like an empty object. This meant that only the True answers were saved in the database. This wouldn't seem so bad, after all it's saving DB space, but then if the user wants to edit their asset and maybe change some of their answers, you need to add back in the questions that they previously answered False on, and that's just ugly. Alternatively, you could change the saving of the formset to save the empty forms too, but I tried this and started running into issues with the forms missing key data, and finally I decided that I was hacking formsets so much that they were no longer useful. So I changed it to a list of forms. Trying to get formsets working took me a couple of days. The list of forms was done quickly and cleanly. When you look at it, it's clear what it's doing, and no more ugly hacks!

In conclusion, while it's possible none of this was clearly written enough to really understand, just know that formsets are great for what they were made for: allowing the user to add multiple objects of the same type; they are not good for a static list of objects that need to be created.

No comments:

Post a Comment