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.