How to handle forms that don't map easily to a single model

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

Depending upon the precise situation, you’ve got a couple different options.

  • Create one generic form covering all the fields, then map those fields to the proper models

  • Use multiple ModelForm on the page, using prefixes to keep form data separate

  • Create a ModelForm for the base entry, what you’ve called “ModelA”, and add extra fields for the other models.

For the one-to-many relationship, that’s the purpose of formsets.

We mostly use that last option. It’s pretty rare for us to create two complete models in a single form on a page, so we tend to build the page primarily focused on the base object, with additional fields to update specific related instances of other models.
(Well, that and formsets, which we use quite a bit.)

1 Like

The formsets’s is not most covered on the community.
I’ve used formsets just a few times, but i think it deserves some love!

Thanks Ken for the insight. Sorry I should have been more clear. I am primary interested in the class based views. The reason I created the Composite form is that I can use the CreateView or UpdateView, and set the model_class to the composite form.

If I hear you correctly, you’re saying take ModelA, and add extra fields to it (from ModelB), and use that as the model_class. That makes sense. However how would one add the ModelC foreignkey models (formsets) to the model_class form so it can be handled by a class based view? Maybe no possible?

Ahh, CBVs are a different issue.

I don’t use any of the CBVs that inherit from either SingleObjectMixin or SingleObjectTemplateResponseMixin when working with any view needing to work with multiple objects.

In practical terms, that means I limit myself to building from View, TemplateView, ListView, and FormView. I’ve found that trying to use any of the single-object views causes me more work than it saves in those situations. (I’d be overriding so many functions that I’m better off not using them.)

1 Like