Django ModelFormSet to answer an ordered list of questions that shouldn't change their order in the formset after submit if answered in a random order

I have to display a Django formset containing a list of predefined questions (a few dozen questions) arranged in a specific order. The user has the freedom to answer whatever questions they want, in an arbitrary order, save their answers, and continue answering later. So there are no mandatory fields.

For instance the user answers the questions 2, 5, 6, and 7:

Question Answer
Question 1
Question 2 Answer to question 2
Question 3
Question 4
Question 5 Answer to question 5
Question 6 Answer to question 6
Question 7 Answer to question 7

Then the user submits the formset. The first issue I had was that after submitting the formset the newly added answers (and their questions) were moved to the top of the formset:

Question Answer
Question 2 Answer to question 2
Question 5 Answer to question 5
Question 6 Answer to question 6
Question 7 Answer to question 7
Question 5
Question 6
Question 7

The (simplified) models I used are:

class Question(models.Model):
    position_nr = models.SmallIntegerField(db_index=True)
    content     = models.CharField(max_length=200)

class Answer(models.Model):
    question        = models.ForeignKey(Question, on_delete=models.PROTECT)
    content         = models.CharField(max_length=100,
                                       blank=True,
                                       null=True,  
                                       ) 

I used a ModelFormSet.

In the simplified versions of my view and forms (see below) the questions are left as they are, as select widgets, but in the real code they are just labels (so the user can not change the question of a form).

VIEW:

def manage_answers(request):
    questions_lst = list(Question.objects.all().order_by('category__position_nr', 'position_nr'))
    AnswerFormSet = modelformset_factory(Answer,
                                         form = AnswerForm,
                                         fields ='__all__',
                                         formset = BaseAnswerFormSet,  # extends BaseModelFormSet class
                                         max_num = 7,  # reduced to 7 just to make testing easier
                                         extra = 7,    # to display all 7 forms from the very beginning
                                         )

    if request.method == 'POST':
        formset = AnswerFormSet(request.POST,
                                form_kwargs={'questions': questions_lst, },
                                queryset = Answer.objects.order_by('question__category__position_nr', 'question__position_nr'),
                                )
        if formset.is_valid():
            formset.save()
            return HttpResponseRedirect(reverse("beta:manage_answers"))

    else:
        formset = AnswerFormSet(form_kwargs={'questions': questions_lst,},
                                queryset = Answer.objects.order_by('question__category__position_nr','question__position_nr'),
                                )

    context = {'formset': formset, }

    return render(request, 'beta/manage_answers.html', context)
 

FORMS:

class BaseAnswerFormSet(BaseModelFormSet):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def get_form_kwargs(self, index):
        kwargs = super().get_form_kwargs(index)
        crt_question = kwargs['questions'][index]
        return {'crt_index': index,
                'crt_question': crt_question,
                }


class AnswerForm(forms.ModelForm):
    # Class attributes:
    ca_instances_lst = []

    def __init__(self, *args,  **kwargs):
        instance = kwargs.get('instance', None)  # if the key 'instance" exists in the kwargs dict
        # instance is an obj of class Answer

        self.crt_question = kwargs.pop('crt_question', None)
        self.crt_index = kwargs.pop('crt_index', None)

        # -------------------------------------
        if instance:
            if self.crt_question.id != instance.question.id:
                AnswerForm.ca_instances_lst.append(kwargs.pop('instance', None))
            else:
                # instance (the answer) belongs to the current question
                pass

        if AnswerForm.ca_instances_lst:
            #  Check if I already have the right answer (for the current question) at the top of the ca_instances_lst list (class attribute)
            if self.crt_question.id == AnswerForm.ca_instances_lst[0].question.id:
                kwargs['instance'] = AnswerForm.ca_instances_lst.pop(0)
                instance = kwargs.get('instance', None)
            else:
                # so ca_instances_lst does not contain the answer for this form (for the current question)
                pass
        # -------------------------------------

        super().__init__(*args, **kwargs)

        self.fields['question'].initial = self.crt_question

    class Meta:
        model = Answer
        fields = '__all__'

In many situations my current solution works just fine: all answers are saved and displayed correctly, for instance:

Question Answer
Question 1
Question 2 Answer to question 2
Question 3
Question 4 Answer to question 4
Question 5
Question 6 Answer to question 6
Question 7

Also in this situation:

Question Answer
Question 1
Question 2 Answer to question 2
Question 3
Question 4
Question 5 Answer to question 5
Question 6 Answer to question 6
Question 7 Answer to question 7

These answers are also saved and displayed correctly:

Question Answer
Question 1
Question 2
Question 3
Question 4 Answer to question 4
Question 5 Answer to question 5
Question 6 Answer to question 6
Question 7 Answer to question 7

but after answering the first question as well, and trying to submit the formset again, I get an error due to the duplicate IDs:

[{}, {}, {}, {'all': ['Please correct the duplicate values below.']}, {}, {}, {}]

Please correct the duplicate data for id.

Question Answer Their respective IDs on request.POST:
Question 1 Answer to question 1 id = 729 (it should be empty)
Question 2 id = 730 (it should be empty)
Question 3 id = 731 (it should be empty)
Question 4 Please correct the duplicate values below. Answer to question 4 id = 729
Question 5 Answer to question 5 id = 730
Question 6 Answer to question 6 id = 731
Question 7 Answer to question 7 id = 732

In request.POST instead of having 'form-0-id': [''], (as expected for a new answer object not saved yet into the db) I have 'form-0-id': ['729'], where 729 is actually the id of "Answer to question 4" answer that Django just tried to display on the first form of the formset. Same for the next ones:

  • form-1-id is 730, where 730 is actually the id of "Answer to question 5"
  • form-2-id is 731, where 731 is actually the id of "Answer to question 6"

These also should have been empty.

So I edited a copy of request.POST where I changed the values of form-0-id, form-1-id and form-2-id to be empty. These also led to an error, due to missing IDs.

OK, so what could be the right solution to have a ModelFormSet behaving like we need? I've searched high and low (in Django documentation, articles, tutorials, books etc) but without success so far.

Back to Top