I find Django is great if your form maps directly to a single model. However I find when I normalize the data for DB storage, this is rarely the case.
A user may interact with say form Foo, which has an instance of ModelA, could have an instance of ModelB, and a one to many relationship with ModelC.
I have not come across any built in machinery or recommended way to handle this, but yet I find it is more common than a single ModelForm?
I made my own “Composite Form” type of form to handle this scenario, but the more I use it, the more I think there must be a better way, and it’s nota good idea. The basic idea of it is that it actions of a set of forms, e.g. when one says full_clean
, it cleans all forms, and collects the errors from all the forms, or if one saves form.save()
, it saves all the forms in the composite form:
"""
cform, or Composite a form, a grouping of forms that behaviours as a single
form so class based views work easily. E.g. form.save() will save each form
in the cform.
"""
from itertools import chain
from django import forms
from django.db import transaction
from django.forms.utils import ErrorDict, ErrorList
# from extend.forms.errors import extract_all_errors
def extract_form_errors(form, plain_text=False):
"""Extract and flatten errors from a single form into a list of strings"""
errors = []
for field in form:
for error in field.errors:
if error:
if plain_text:
errors.append(f'{field.label}: {error}')
else:
errors.append(f'<span class="text-bold">{field.label}</span>: {error}')
errors.extend(form.non_field_errors())
return errors
def extract_all_errors(obj):
"""Extracts all errors from a form or formset
Parameter: obj
Obj could be an instance of a form or formset
"""
errors = []
if isinstance(obj, forms.Form) or isinstance(obj, forms.ModelForm):
errors.extend(extract_form_errors(obj))
elif isinstance(obj, forms.BaseFormSet) or isinstance(obj, forms.BaseModelFormSet):
for form in obj: # obj is a formset
errors.extend(extract_form_errors(form))
errors.extend(obj.non_form_errors())
else:
raise ValueError(f"Error: Unknown form type: {type(obj)}")
return errors
class ModelCompositeForm(forms.ModelForm):
"""Subclass this form and set the form_class_list class attribute."""
form_class_list = [] # a list of form_class that this componsite form controls
def __init__(self, *args, init_kwargs={}, **kwargs):
"""
If one has a form with a model 'Foo', and a formset with model 'Bar',
the forms will be attached to self, as attributes
with the name of their form prefix, which is their model name, or
model name + '_set' for a formset, e.g.
form.foo and form.bar_set
Paramater: kwargs['data']
request.POST data
Paramater: kwargs['instance']
Some object, which will probably be the main model type for the composite
form set, which is used to initialise the oher forms.
Parameter: init_kwargs
To pass kwargs in to a specific form init, pass in a kwarg where
the key is the form_class, and the value is the dict of extra_kwargs
to pass into that form only.
e.g. If one has FooForm, pass
"""
# self.instance is saved to aid custom override logic that depend on the instance
self.data = kwargs.get('data')
self.files = kwargs.get('files')
self.is_bound = self.data is not None or self.files is not None
self._errors = None # Stores the errors after is_valid() has been called.
self._non_form_errors = None
self.error_class = ErrorList
self.instance = kwargs.get('instance')
self.renderer = kwargs.get('renderer') # Required when rendering form errors
self.cform = True # used in _errors.html template
# get_form_kwargs will add kwargs['prefix'] = self.prefix (which is None by default)
kwargs.pop('prefix')
self.form_list = []
# Dont use get forms here, we need all forms at page load
base_kwargs = {
'data': self.data,
'files': self.files,
'renderer': kwargs['renderer'],
}
for form_class in self.form_class_list:
form_kwargs = base_kwargs.copy()
form_kwargs['prefix'] = prefix = self.get_prefix(form_class)
if extra_kwargs := init_kwargs.get(form_class):
form_kwargs.update(extra_kwargs)
# So now form_kwargs consists for data/files/prefix/init_kwargs[form_class]
if type(self.instance) is self.get_model(form_class):
form = form_class(instance=self.instance, **form_kwargs)
else:
form = form_class(self.instance, **form_kwargs)
setattr(self, prefix, form)
self.form_list.append(form)
def get_model(self, form_class):
# Check formset subclass first, as formset comes from form
if issubclass(form_class, forms.BaseModelFormSet):
return form_class.form._meta.model
else:
return form_class._meta.model
def get_prefix(self, form_class):
"""Returns lower case model name, e.g. 'applicant'"""
# Check formset subclass first, as formset comes from form
model = self.get_model(form_class)
model_name = model._meta.model_name
if issubclass(form_class, forms.BaseModelFormSet):
return model_name + '_set'
else:
return model_name
def get_forms(self):
"""Useful for overriding to define conditionally required forms"""
return self.form_list
def __iter__(self):
"""Iterating a form yields its fields, yield all fields in the composite
field. Useful for loop through all fields to print errors.
form_list may contain formsets, which need to be flattened
"""
flat = []
for form in self.get_forms():
if isinstance(form, forms.BaseFormSet):
flat.extend(form)
else:
flat.append(form)
return chain(*flat)
def __repr__(self):
return '\n'.join(repr(x) for x in self.get_forms())
def has_changed(self):
return any(form.has_changed() for form in self.get_forms())
def full_clean(self):
self._errors = ErrorDict()
self._non_form_errors = ErrorList()
if not self.is_bound: # Stop further processing.
return
for form in self.get_forms():
form.full_clean()
if isinstance(form, forms.BaseFormSet):
formset = form
for errors in formset.errors:
# A formset's .errors() return a list of each forms .errors
self._errors.update(errors)
self._non_form_errors.extend(formset.non_form_errors())
else:
self._errors.update(form.errors)
def save(self): # commit=True is the only other arg
with transaction.atomic():
# --- Application ---
for form in self.get_forms():
saved_obj = form.save()
if isinstance(saved_obj, type(self.instance)):
self.instance = saved_obj
return self.instance # NB!! the view's self.object is assigned to this value