ValidationError ignored in Custom Django Crispy Form

Viewed 1649

I have a rather complex Django form that affects 3 models and part of which includes an inline formset. I found a nice solution to building the form at https://dev.to/zxenia/django-inline-formsets-with-class-based-views-and-crispy-forms-14o6. I extended that solution and added a third model in a similar way that the formset was added (using a custom Django Crispy Form and inserting it using the Crispy Forms Layout features).

My problem is that any validation errors raised on either of the two inserted forms (the formset and the small subform) are simply ignored - the main form posts correctly and raised ValidationErrors are displayed in the form as errors allowing the user to correct any mistakes and its data is correctly saved to the database. If the subform and formset are valid, their data gets saved correctly as well. However, if the data in the subform and formset is not valid, the form never shows the errors to give the user a chance to correct their mistake, and the data is simply ignored and never saved to the database - the main model's data saves fine though.

My question is, how do I get the form to refresh with errors displayed in the added subform and formset allowing the user to correct their mistakes?

Most of the code below is from the quite good post referenced above with a third model added

Models:

from django.db import models
from django.contrib.auth.models import User


class Collection(models.Model):
    subject = models.CharField(max_length=300, blank=True)
    owner = models.CharField(max_length=300, blank=True)
    note = models.TextField(blank=True)
    created_by = models.ForeignKey(User,
        related_name="collections", blank=True, null=True,
        on_delete=models.SET_NULL)

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


class CollectionTitle(models.Model):
    """
    A Class for Collection titles.

    """
    collection = models.ForeignKey(Collection,
        related_name="has_titles", on_delete=models.CASCADE)
    name = models.CharField(max_length=500, verbose_name="Title")
    language = models.CharField(max_length=3)

Class CollectionTxn(models.Model):
    """
    A Class for Collection transactions.

    """
    collection = models.ForeignKey(Collection,
        related_name="has_txn", on_delete=models.CASCADE)
    number_received= models.IntegerField()
    date_received= models.DateField()

    class Meta:
        '''
        If 2 rows are entered with the same information, a validation error is raised, but it just
        doesn't save the data at all instead of refreshing the form showing the error.
        '''
        unique_together = ('number_received', 'date_received')

forms.py:

from django import forms
from .models import Collection, CollectionTitle
from django.forms.models import inlineformset_factory
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Fieldset, Div, Row, HTML, ButtonHolder, Submit
from .custom_layout_object import Formset, Subform

import re


class CollectionTitleForm(forms.ModelForm):

    class Meta:
        model = CollectionTitle
        exclude = ()

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

        formtag_prefix = re.sub('-[0-9]+$', '', kwargs.get('prefix', ''))

        self.helper = FormHelper()
        self.helper.form_tag = False
        self.helper.layout = Layout(
            Row(
                Field('name'),
                Field('language'),
                Field('DELETE'),
                css_class='formset_row-{}'.format(formtag_prefix)
            )
        )


CollectionTitleFormSet = inlineformset_factory(
    Collection, CollectionTitle, form=CollectionTitleForm,
    fields=['name', 'language'], extra=1, can_delete=True
)


class CollectionForm(forms.ModelForm):

    class Meta:
        model = Collection
        exclude = ['created_by', ]

    def __init__(self, *args, **kwargs):
        super(CollectionForm, self).__init__(*args, **kwargs)
        self.helper = FormHelper()
        self.helper.form_tag = True
        self.helper.form_class = 'form-horizontal'
        self.helper.label_class = 'col-md-3 create-label'
        self.helper.field_class = 'col-md-9'
        self.helper.layout = Layout(
            Div(
                Field('subject'),
                Field('owner'),
                Fieldset('Add titles',
                         Formset('titles')),
                Field('note'),
                Subform('transactions'),
                HTML("<br>"),
                ButtonHolder(Submit('submit', 'Save')),
            )
        )

class CollectionTxnForm(forms.ModelForm):
    
    class Meta:
        model = CollectionTxn
        exclude = ()

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['collection'].widget = HiddenInput()
        self.helper = FormHelper()
        self.helper.form_tag = False
        self.helper.layout = Layout(
            Row(
                Field('number_received'),
                Field('date_received'),
            )
        )

views.py:

from .models import *
from .forms import *
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse_lazy
from django.db import transaction

class CollectionCreate(CreateView):
    model = Collection
    template_name = 'mycollections/collection_create.html'
    form_class = CollectionForm
    success_url = None

    def get_context_data(self, **kwargs):
        data = super(CollectionCreate, self).get_context_data(**kwargs)
        if self.request.POST:
            data['titles'] = CollectionTitleFormSet(self.request.POST)
            data['transactions'] = CollectionTrxForm(self.request.POST)
        else:
            data['titles'] = CollectionTitleFormSet()
            data['transactions'] = CollectionTrxForm()
        return data

    def form_valid(self, form):
        context = self.get_context_data()
        titles = context['titles']
        transactions = context['transactions']
        with transaction.atomic():
            form.instance.created_by = self.request.user
            self.object = form.save()
            if titles.is_valid():
                titles.instance = self.object
                titles.save()
            if transactions.is_valid():
                transactions.save()
        return super(CollectionCreate, self).form_valid(form)

    def get_success_url(self):
        return reverse_lazy('mycollections:collection_detail', kwargs={'pk': self.object.pk})

custom_layout_object.py

from crispy_forms.layout import LayoutObject, TEMPLATE_PACK
from django.shortcuts import render
from django.template.loader import render_to_string

class Formset(LayoutObject):
    template = "mycollections/formset.html"

    def __init__(self, formset_name_in_context, template=None):
        self.formset_name_in_context = formset_name_in_context
        self.fields = []
        if template:
            self.template = template

    def render(self, form, form_style, context, template_pack=TEMPLATE_PACK):
        formset = context[self.formset_name_in_context]
        return render_to_string(self.template, {'formset': formset})

class SubForm(LayoutObject):
    template = "mycollections/subform.html"

    def __init__(self, subform_name_in_context, template=None):
        self.subform_name_in_context = subform_name_in_context
        self.fields = []
        if template:
            self.template = template

    def render(self, subform, form_style, context, template_pack=TEMPLATE_PACK):
        subform = context[self.subform_name_in_context]
        return render_to_string(self.template, {'subform': subform})

formset.html

{% load crispy_forms_tags %}
{% load staticfiles %}

<style type="text/css">
  .delete-row {
    align-self: center;
  }
</style>

{{ formset.management_form|crispy }}

{% for form in formset.forms %}
  {% for hidden in form.hidden_fields %}
      {{ hidden|as_crispy_field }}
  {% endfor %}
  {% crispy form %}
{% endfor %}

<br>
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>
<script src="{% static 'mycollections/libraries/django-dynamic-formset/jquery.formset.js' %}"></script>
<script type="text/javascript">
    $('.formset_row-{{ formset.prefix }}').formset({
        addText: 'add another',
        deleteText: 'remove',
        prefix: '{{ formset.prefix }}',
    });
</script>

subform.html

{% load crispy_forms_tags %}
{% crispy subform %}

collection_create.html

{% extends "mycollections/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container">
    <div class="card">
        <div class="card-header">
            Create collection
        </div>
        <div class="card-body">
             {% crispy form %}
        </div>
    </div>
</div>
{% endblock content %}

Basically, for the fields associated with the formset and subform added to the layout, validation errors are still raised, but they do not bubble up to the form level to show the errors, they are just ignored and the data is never saved. The "main" model works fine and validationerrors are displayed for its fields. If there is no invalid data, the main form, the subform, and the formset's data are all saved correctly. If there is invalid data in the formset or subform, the user never gets a chance to correct that data.

Any help as to where I would add the code needed so that if any invalid data entered in the formset or subform would cause the form to refresh displaying errors instead of just ignoring and not saving invalid data would be appreciated.

1 Answers

After much debugging and analyzing code, both my own and Crispy, I have solved my own problem. It was simply a matter of checking for the subform and formset validations, and if not valid, re-rendering the form.

Here is the new form_valid() method from the view that does this:

    def form_valid(self, form):
        context = self.get_context_data()
        titles = context['titles']
        transactions = context['transactions']
        with transaction.atomic():
            form.instance.created_by = self.request.user
            if titles.is_valid() and transactions_is_valid():
                self.object = form.save() #only save form if other subforms validate
                titles.instance = self.object
                # Any other field processing goes here
                titles.save()
                transactions.save()
            else:
                # If any subform or subformset is invalid, re-render the form showing errors
                context.update({'titles': titles})
                context.update({'transactions': transactions})
                return self.render_to_response(context)
        return super(CollectionCreate, self).form_valid(form)

With this, any clean methods in the formset and subform or if there are any other errors (for example if no two rows of the formset can be the same because unique_together() declared in model),the form will refresh showing all errors on all three combined form/formsets allowing the user to correct those errors.

Now - to do it with Ajax so the page does not refresh :)

Related