inlineformset_factory requiring field on empty rows sporadically

Hello,

I’ve got an issue with formsets that seems sporadic and I’ve tried everything I can think of.
In my app, there are two different forms (Medication, HouseholdMember) that are implemented (I think) the exact same way, but when I use them, the Medication form submits fine and the HouseholdMember form gives a validation error.

For both forms, I am using a formset and django-crispy-forms, and I’m (for sake of this example) only displaying two rows in the formset.
Medication: If I fill out the first row of the formset, leaving the second row completely empty, it submits fine.
HouseholdMember: If I fill out the first row of the formset, leaving the second row completely empty, it gives an error:
[{}, {'full_name': ['This field is required.']}]

If you look over my models.py form below, you will see there is no difference in the “required fields” of the Medication and HouseholdMember models. So, I’m stuck on why the HouseholdMember formset gives an error when submitting that the “full_name” field is required (presumably for the second row). It doesn’t look any different than the Medication model’s “medication” field.

If there is any direction you can point me in the further troubleshoot this, I would greatly appreciate it!

models.py

class HouseholdMember(TheBaseClass):
    history = HistoricalRecords()
    client = models.ForeignKey(Client, on_delete=models.CASCADE)
    full_name = models.CharField(max_length=100, default='')
    relationship = models.CharField(max_length=50, choices=Relationship.choices, default='')
    age = models.SmallIntegerField(blank=True, null=True)
    member_info = models.TextField(blank=True, default='')
    app_client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name='app_client_householdmember_set', blank=True, null=True)

    class Meta:
        verbose_name = "Household Member"
        verbose_name_plural = "Household Members"

    def __str__(self):
        return str(self.full_name)

class Medication(TheBaseClass):
    history = HistoricalRecords()
    client = models.ForeignKey(Client, on_delete=models.CASCADE)
    medication = models.CharField(max_length=100, default='')
    reason = models.TextField(blank=True, default='')
    dosage_frequency = models.CharField(max_length=100, blank=True, default='')
    prescribed_by = models.CharField(max_length=100, blank=True, default='')
    compliance = models.CharField(max_length=100, blank=True, default='')
    past_current = models.CharField(max_length=50, choices=PastCurrent.choices, blank=True, default='')
    status = models.CharField(max_length=50, choices=MedicationStatus.choices, blank=True, default='')

    class Meta:
        verbose_name = "Medication"
        verbose_name_plural = "Medications"

    def __str__(self):
        return str(self.medication)

views.py

def householdmembers_add(request, client_id):
    formset = HouseholdMemberFormset(request.POST or None)
    helper = HouseholdMemberFormsetHelper()
    cancel_url = reverse('clients:detail', args=[client_id])
    client = Client.objects.get(id=client_id)

    if request.method == "POST":
        if formset.is_valid():
            formset.instance = client
            formset.save()
            messages.success(request, 'Household members successfully added!')
            return redirect('clients:detail', client_id)
        else:
            messages.error(request, mark_safe('Error adding new household members.<br/>' + str(formset.errors)))
            return redirect('clients:householdmembers_add', client_id)

    context = {
        "page_width": "full", # added because this is a big horizontal table; leave off for regular size template layout
        "formset": formset,
        "helper": helper,
        "cancel_url": cancel_url,
        "client_full_name": client.full_name,
        "obj_name": "Household Members"
    }

    return render(request, 'generic_add.html', context)

def medications_add(request, client_id):
    formset = MedicationFormset(request.POST or None)
    helper = MedicationFormsetHelper()
    cancel_url = reverse('clients:detail', args=[client_id])
    client = Client.objects.get(id=client_id)

    if request.method == "POST":
        if formset.is_valid():
            formset.instance = client
            formset.save()
            messages.success(request, 'Medications successfully added!')
            return redirect('clients:detail', client_id)
        else:
            messages.error(request, mark_safe('Error adding new medications.<br/>' + str(formset.errors)))
            return redirect('clients:medications_add', client_id)

    context = {
        "page_width": "full", # added because this is a big horizontal table; leave off for regular size template layout
        "formset": formset,
        "helper": helper,
        "cancel_url": cancel_url,
        "client_full_name": client.full_name,
        "obj_name": "Medications"
    }

    return render(request, 'generic_add.html', context)

forms.py

class HouseholdMemberForm(ModelForm):
	class Meta:
		model = HouseholdMember
		exclude = ['client']
		widgets = {
			'member_info': widgets.Textarea(attrs={'rows': '3'})
		}

	app_client = ClientModelChoiceField(label="App Client Record", queryset=Client.objects.all(), required=False)

	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		self.helper = FormHelper()
		self.helper.layout = Layout(
			Fieldset('',
				'full_name',
				'relationship',
				'age',
				'member_info',
				'app_client',
			),
			Hidden('hidden_client_id', '{{ c_id }}'),
			ButtonHolder(
				Submit('submit', 'Save'),
				HTML("<a href='{{ view.get_success_url }}' class='btn btn-light'>Cancel</a>")
			)
		)


class HouseholdMemberFormsetHelper(FormHelper):
	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		self.form_method = 'post'
		# Hide form labels just for Formset (multi-row) layout
		self.form_show_labels = False
		self.layout = Layout(
			HTML("""
				<table class='table mb-5'>
				<tr>
					<th style='text-align: center;'>Full Name</th>
					<th style='text-align: center;'>Relationship</th>
					<th style='text-align: center;'>Age</th>
					<th style='text-align: center;'>Member Info</th>
					<th style='text-align: center;'>App Client Record</th>
				</tr>
				<tr>
					<td>
			"""),
			Field('full_name'),
			HTML("""
					</td>
					<td>
			"""),
			Field('relationship'),
			HTML("""
					</td>
					<td>
			"""),
			Field('age'),
			HTML("""
					</td>
					<td>
			"""),
			Field('member_info'),
			HTML("""
					</td>
					<td>
					"""),
			Field('app_client'),
			HTML("""
					</td>
					</tr>
				</table>
			"""),
		)
		self.add_input(Submit("submit", "Save Multiple Entries"))
		# Cancel action handled on the generic_add.html page JS
		self.add_input(Button("cancelFormset", "Cancel"))


HouseholdMemberFormset = inlineformset_factory(
	Client,
	HouseholdMember,
	form=HouseholdMemberForm,
	fk_name='client',
	min_num=1,  # minimum number of forms that must be filled in
	extra=1,  # number of empty forms to display
	# can_delete=False  # show a checkbox in each form to delete the row
)

class MedicationForm(ModelForm):
	class Meta:
		model = Medication
		exclude = ['client']
		widgets = {
			'reason': widgets.Textarea(attrs={'rows': '3'})
		}

	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		self.helper = FormHelper()
		self.helper.layout = Layout(
			Fieldset('',
				'medication',
				  'reason',
				  'dosage_frequency',
				  'prescribed_by',
				  'compliance',
				  'past_current',
				  'status',
			),
			Hidden('hidden_client_id', '{{ c_id }}'),
			ButtonHolder(
				Submit('submit', 'Save'),
				HTML("<a href='{{ view.get_success_url }}' class='btn btn-light'>Cancel</a>")
			)
		)


class MedicationFormsetHelper(FormHelper):
	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		self.form_method = 'post'
		# Hide form labels just for Formset (multi-row) layout
		self.form_show_labels = False
		self.layout = Layout(
			HTML("""
				<table class='table mb-5'>
				<tr>
					<th style='text-align: center;'>Medication</th>
					<th style='text-align: center;'>Reason</th>
					<th style='text-align: center;'>Dosage/Frequency</th>
					<th style='text-align: center;'>Prescribed By</th>
					<th style='text-align: center;'>Compliance</th>
					<th style='text-align: center;'>Past/Current</th>
					<th style='text-align: center;'>Status</th>
				</tr>
				<tr>
					<td>
			"""),
			Field('medication'),
			HTML("""
					</td>
					<td>
			"""),
			Field('reason'),
			HTML("""
					</td>
					<td>
			"""),
			Field('dosage_frequency'),
			HTML("""
					</td>
					<td>
			"""),
			Field('prescribed_by'),
			HTML("""
					</td>
					<td>
					"""),
			Field('compliance'),
			HTML("""
					</td>
					<td>
			"""),
			Field('past_current'),
			HTML("""
					</td>
					<td>
			"""),
			Field('status'),
			HTML("""
					</td>
				</tr>
				</table>
			"""),
		)
		self.add_input(Submit("submit", "Save Multiple Entries"))
		# Cancel action handled on the generic_add.html page JS
		self.add_input(Button("cancelFormset", "Cancel"))


MedicationFormset = inlineformset_factory(
	Client,
	Medication,
	form=MedicationForm,
	min_num=1,  # minimum number of forms that must be filled in
	extra=1,  # number of empty forms to display
	# can_delete=False  # show a checkbox in each form to delete the row
)

generic_add.html

{% load crispy_forms_tags %}
<form action="" method="POST" enctype="multipart/form-data">
	{% csrf_token %}

	{% if formset %}
		{% crispy formset helper %}
	{% else %}
		{% crispy form %}
	{% endif %}
</form>

I’m not seeing anything obvious here.

If I had to debug this, I’d start from the fundamentals. I’d look at what’s being posted back to the server in each of the two cases. Then I’d look at how the views are interpreting the data. I’d probably look at what is being populated from request.POST, and then I’d look at the forms being generated by the formset.

1 Like

To close the loop on this…

I ran into someone IRL that could look over my shoulder and see what I was doing with the two formsets.
It turns out the difference was in another field on the HouseholdMember model.

Notice how, in models.py, the “relationship” field on HouseholdMember does not default to a blank value:
relationship = models.CharField(max_length=50, choices=Relationship.choices, default='')

But other “choice fields” on the Medication model do:
past_current = models.CharField(max_length=50, choices=PastCurrent.choices, blank=True, default='')

Because the relationship field didn’t have a blank value, it was defaulting to a value, so the extra rows in the formset, that I thought were “empty” were not actually empty.

I needed to add blank=True to the relationship field, and that fixed it!