442 lines
18 KiB
Python
442 lines
18 KiB
Python
|
|
from __future__ import unicode_literals
|
||
|
|
|
||
|
|
from django.forms import ValidationError
|
||
|
|
from django.core.exceptions import NON_FIELD_ERRORS
|
||
|
|
from django.forms.formsets import TOTAL_FORM_COUNT
|
||
|
|
from django.forms.models import (
|
||
|
|
BaseModelFormSet, modelformset_factory,
|
||
|
|
ModelForm, _get_foreign_key, ModelFormMetaclass, ModelFormOptions
|
||
|
|
)
|
||
|
|
from django.db.models.fields.related import ForeignObjectRel
|
||
|
|
from django.utils.html import format_html_join
|
||
|
|
|
||
|
|
|
||
|
|
from modelcluster.models import get_all_child_relations
|
||
|
|
|
||
|
|
|
||
|
|
class BaseTransientModelFormSet(BaseModelFormSet):
|
||
|
|
""" A ModelFormSet that doesn't assume that all its initial data instances exist in the db """
|
||
|
|
def _construct_form(self, i, **kwargs):
|
||
|
|
# Need to override _construct_form to avoid calling to_python on an empty string PK value
|
||
|
|
|
||
|
|
if self.is_bound and i < self.initial_form_count():
|
||
|
|
pk_key = "%s-%s" % (self.add_prefix(i), self.model._meta.pk.name)
|
||
|
|
pk = self.data[pk_key]
|
||
|
|
if pk == '':
|
||
|
|
kwargs['instance'] = self.model()
|
||
|
|
else:
|
||
|
|
pk_field = self.model._meta.pk
|
||
|
|
to_python = self._get_to_python(pk_field)
|
||
|
|
pk = to_python(pk)
|
||
|
|
kwargs['instance'] = self._existing_object(pk)
|
||
|
|
if i < self.initial_form_count() and 'instance' not in kwargs:
|
||
|
|
kwargs['instance'] = self.get_queryset()[i]
|
||
|
|
if i >= self.initial_form_count() and self.initial_extra:
|
||
|
|
# Set initial values for extra forms
|
||
|
|
try:
|
||
|
|
kwargs['initial'] = self.initial_extra[i - self.initial_form_count()]
|
||
|
|
except IndexError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# bypass BaseModelFormSet's own _construct_form
|
||
|
|
return super(BaseModelFormSet, self)._construct_form(i, **kwargs)
|
||
|
|
|
||
|
|
def save_existing_objects(self, commit=True):
|
||
|
|
# Need to override _construct_form so that it doesn't skip over initial forms whose instance
|
||
|
|
# has a blank PK (which is taken as an indication that the form was constructed with an
|
||
|
|
# instance not present in our queryset)
|
||
|
|
|
||
|
|
self.changed_objects = []
|
||
|
|
self.deleted_objects = []
|
||
|
|
if not self.initial_forms:
|
||
|
|
return []
|
||
|
|
|
||
|
|
saved_instances = []
|
||
|
|
forms_to_delete = self.deleted_forms
|
||
|
|
for form in self.initial_forms:
|
||
|
|
obj = form.instance
|
||
|
|
if form in forms_to_delete:
|
||
|
|
if obj.pk is None:
|
||
|
|
# no action to be taken to delete an object which isn't in the database
|
||
|
|
continue
|
||
|
|
self.deleted_objects.append(obj)
|
||
|
|
self.delete_existing(obj, commit=commit)
|
||
|
|
elif form.has_changed():
|
||
|
|
self.changed_objects.append((obj, form.changed_data))
|
||
|
|
saved_instances.append(self.save_existing(form, obj, commit=commit))
|
||
|
|
if not commit:
|
||
|
|
self.saved_forms.append(form)
|
||
|
|
return saved_instances
|
||
|
|
|
||
|
|
|
||
|
|
def transientmodelformset_factory(model, formset=BaseTransientModelFormSet, **kwargs):
|
||
|
|
return modelformset_factory(model, formset=formset, **kwargs)
|
||
|
|
|
||
|
|
|
||
|
|
class BaseChildFormSet(BaseTransientModelFormSet):
|
||
|
|
inherit_kwargs = None
|
||
|
|
|
||
|
|
def __init__(self, data=None, files=None, instance=None, queryset=None, **kwargs):
|
||
|
|
if instance is None:
|
||
|
|
self.instance = self.fk.remote_field.model()
|
||
|
|
else:
|
||
|
|
self.instance = instance
|
||
|
|
|
||
|
|
self.rel_name = ForeignObjectRel(self.fk, self.fk.remote_field.model, related_name=self.fk.remote_field.related_name).get_accessor_name()
|
||
|
|
|
||
|
|
if queryset is None:
|
||
|
|
queryset = getattr(self.instance, self.rel_name).all()
|
||
|
|
|
||
|
|
super().__init__(data, files, queryset=queryset, **kwargs)
|
||
|
|
|
||
|
|
def save(self, commit=True):
|
||
|
|
# The base ModelFormSet's save(commit=False) will populate the lists
|
||
|
|
# self.changed_objects, self.deleted_objects and self.new_objects;
|
||
|
|
# use these to perform the appropriate updates on the relation's manager.
|
||
|
|
saved_instances = super().save(commit=False)
|
||
|
|
|
||
|
|
manager = getattr(self.instance, self.rel_name)
|
||
|
|
|
||
|
|
# if model has a sort_order_field defined, assign order indexes to the attribute
|
||
|
|
# named in it
|
||
|
|
if self.can_order and hasattr(self.model, 'sort_order_field'):
|
||
|
|
sort_order_field = getattr(self.model, 'sort_order_field')
|
||
|
|
for i, form in enumerate(self.ordered_forms):
|
||
|
|
setattr(form.instance, sort_order_field, i)
|
||
|
|
|
||
|
|
# If the manager has existing instances with a blank ID, we have no way of knowing
|
||
|
|
# whether these correspond to items in the submitted data. We'll assume that they do,
|
||
|
|
# as that's the most common case (i.e. the formset contains the full set of child objects,
|
||
|
|
# not just a selection of additions / updates) and so we delete all ID-less objects here
|
||
|
|
# on the basis that they will be re-added by the formset saving mechanism.
|
||
|
|
no_id_instances = [obj for obj in manager.all() if obj.pk is None]
|
||
|
|
if no_id_instances:
|
||
|
|
manager.remove(*no_id_instances)
|
||
|
|
|
||
|
|
manager.add(*saved_instances)
|
||
|
|
manager.remove(*self.deleted_objects)
|
||
|
|
|
||
|
|
self.save_m2m() # ensures any parental-m2m fields are saved.
|
||
|
|
if commit:
|
||
|
|
manager.commit()
|
||
|
|
|
||
|
|
return saved_instances
|
||
|
|
|
||
|
|
def clean(self, *args, **kwargs):
|
||
|
|
self.validate_unique()
|
||
|
|
return super().clean(*args, **kwargs)
|
||
|
|
|
||
|
|
def validate_unique(self):
|
||
|
|
'''This clean method will check for unique_together condition'''
|
||
|
|
# Collect unique_checks and to run from all the forms.
|
||
|
|
all_unique_checks = set()
|
||
|
|
all_date_checks = set()
|
||
|
|
forms_to_delete = self.deleted_forms
|
||
|
|
valid_forms = [form for form in self.forms if form.is_valid() and form not in forms_to_delete]
|
||
|
|
for form in valid_forms:
|
||
|
|
unique_checks, date_checks = form.instance._get_unique_checks(
|
||
|
|
include_meta_constraints=True
|
||
|
|
)
|
||
|
|
all_unique_checks.update(unique_checks)
|
||
|
|
all_date_checks.update(date_checks)
|
||
|
|
|
||
|
|
errors = []
|
||
|
|
# Do each of the unique checks (unique and unique_together)
|
||
|
|
for uclass, unique_check in all_unique_checks:
|
||
|
|
seen_data = set()
|
||
|
|
for form in valid_forms:
|
||
|
|
# Get the data for the set of fields that must be unique among the forms.
|
||
|
|
row_data = (
|
||
|
|
field if field in self.unique_fields else form.cleaned_data[field]
|
||
|
|
for field in unique_check if field in form.cleaned_data
|
||
|
|
)
|
||
|
|
# Reduce Model instances to their primary key values
|
||
|
|
row_data = tuple(d._get_pk_val() if hasattr(d, '_get_pk_val') else d
|
||
|
|
for d in row_data)
|
||
|
|
if row_data and None not in row_data:
|
||
|
|
# if we've already seen it then we have a uniqueness failure
|
||
|
|
if row_data in seen_data:
|
||
|
|
# poke error messages into the right places and mark
|
||
|
|
# the form as invalid
|
||
|
|
errors.append(self.get_unique_error_message(unique_check))
|
||
|
|
form._errors[NON_FIELD_ERRORS] = self.error_class([self.get_form_error()])
|
||
|
|
# remove the data from the cleaned_data dict since it was invalid
|
||
|
|
for field in unique_check:
|
||
|
|
if field in form.cleaned_data:
|
||
|
|
del form.cleaned_data[field]
|
||
|
|
# mark the data as seen
|
||
|
|
seen_data.add(row_data)
|
||
|
|
|
||
|
|
if errors:
|
||
|
|
raise ValidationError(errors)
|
||
|
|
|
||
|
|
|
||
|
|
def childformset_factory(
|
||
|
|
parent_model, model, form=ModelForm,
|
||
|
|
formset=BaseChildFormSet, fk_name=None, fields=None, exclude=None,
|
||
|
|
extra=3, can_order=False, can_delete=True, max_num=None, validate_max=False,
|
||
|
|
formfield_callback=None, widgets=None, min_num=None, validate_min=False,
|
||
|
|
inherit_kwargs=None, formsets=None, exclude_formsets=None
|
||
|
|
):
|
||
|
|
|
||
|
|
fk = _get_foreign_key(parent_model, model, fk_name=fk_name)
|
||
|
|
# enforce a max_num=1 when the foreign key to the parent model is unique.
|
||
|
|
if fk.unique:
|
||
|
|
max_num = 1
|
||
|
|
validate_max = True
|
||
|
|
|
||
|
|
if exclude is None:
|
||
|
|
exclude = []
|
||
|
|
exclude += [fk.name]
|
||
|
|
|
||
|
|
if issubclass(form, ClusterForm) and (formsets is not None or exclude_formsets is not None):
|
||
|
|
# the modelformset_factory helper that we ultimately hand off to doesn't recognise
|
||
|
|
# formsets / exclude_formsets, so we need to prepare a specific subclass of our `form`
|
||
|
|
# class, with these pre-embedded in Meta, to use as the base form
|
||
|
|
|
||
|
|
# If parent form class already has an inner Meta, the Meta we're
|
||
|
|
# creating needs to inherit from the parent's inner meta.
|
||
|
|
bases = (form.Meta,) if hasattr(form, "Meta") else ()
|
||
|
|
Meta = type("Meta", bases, {
|
||
|
|
'formsets': formsets,
|
||
|
|
'exclude_formsets': exclude_formsets,
|
||
|
|
})
|
||
|
|
|
||
|
|
# Instantiate type(form) in order to use the same metaclass as form.
|
||
|
|
form = type(form)("_ClusterForm", (form,), {"Meta": Meta})
|
||
|
|
|
||
|
|
kwargs = {
|
||
|
|
'form': form,
|
||
|
|
'formfield_callback': formfield_callback,
|
||
|
|
'formset': formset,
|
||
|
|
'extra': extra,
|
||
|
|
'can_delete': can_delete,
|
||
|
|
# if the model supplies a sort_order_field, enable ordering regardless of
|
||
|
|
# the current setting of can_order
|
||
|
|
'can_order': (can_order or hasattr(model, 'sort_order_field')),
|
||
|
|
'fields': fields,
|
||
|
|
'exclude': exclude,
|
||
|
|
'max_num': max_num,
|
||
|
|
'validate_max': validate_max,
|
||
|
|
'widgets': widgets,
|
||
|
|
'min_num': min_num,
|
||
|
|
'validate_min': validate_min,
|
||
|
|
}
|
||
|
|
FormSet = transientmodelformset_factory(model, **kwargs)
|
||
|
|
FormSet.fk = fk
|
||
|
|
|
||
|
|
# A list of keyword argument names that should be passed on from ClusterForm's constructor
|
||
|
|
# to child forms in this formset
|
||
|
|
FormSet.inherit_kwargs = inherit_kwargs
|
||
|
|
|
||
|
|
return FormSet
|
||
|
|
|
||
|
|
|
||
|
|
class ClusterFormOptions(ModelFormOptions):
|
||
|
|
def __init__(self, options=None):
|
||
|
|
super().__init__(options=options)
|
||
|
|
self.formsets = getattr(options, 'formsets', None)
|
||
|
|
self.exclude_formsets = getattr(options, 'exclude_formsets', None)
|
||
|
|
|
||
|
|
|
||
|
|
class ClusterFormMetaclass(ModelFormMetaclass):
|
||
|
|
extra_form_count = 3
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def child_form(cls):
|
||
|
|
return ClusterForm
|
||
|
|
|
||
|
|
def __new__(cls, name, bases, attrs):
|
||
|
|
try:
|
||
|
|
parents = [b for b in bases if issubclass(b, ClusterForm)]
|
||
|
|
except NameError:
|
||
|
|
# We are defining ClusterForm itself.
|
||
|
|
parents = None
|
||
|
|
|
||
|
|
# grab any formfield_callback that happens to be defined in attrs -
|
||
|
|
# so that we can pass it on to child formsets - before ModelFormMetaclass deletes it.
|
||
|
|
# BAD METACLASS NO BISCUIT.
|
||
|
|
formfield_callback = attrs.get('formfield_callback')
|
||
|
|
|
||
|
|
new_class = super().__new__(cls, name, bases, attrs)
|
||
|
|
if not parents:
|
||
|
|
return new_class
|
||
|
|
|
||
|
|
# ModelFormMetaclass will have set up new_class._meta as a ModelFormOptions instance;
|
||
|
|
# replace that with ClusterFormOptions so that we can access _meta.formsets
|
||
|
|
opts = new_class._meta = ClusterFormOptions(getattr(new_class, 'Meta', None))
|
||
|
|
if opts.model:
|
||
|
|
formsets = {}
|
||
|
|
|
||
|
|
for rel in get_all_child_relations(opts.model):
|
||
|
|
# to build a childformset class from this relation, we need to specify:
|
||
|
|
# - the base model (opts.model)
|
||
|
|
# - the child model (rel.field.model)
|
||
|
|
# - the fk_name from the child model to the base (rel.field.name)
|
||
|
|
|
||
|
|
rel_name = rel.get_accessor_name()
|
||
|
|
|
||
|
|
# apply 'formsets' and 'exclude_formsets' rules from meta
|
||
|
|
if opts.exclude_formsets is not None and rel_name in opts.exclude_formsets:
|
||
|
|
# formset is explicitly excluded
|
||
|
|
continue
|
||
|
|
elif opts.formsets is not None and rel_name not in opts.formsets:
|
||
|
|
# a formset list has been specified and this isn't on it
|
||
|
|
continue
|
||
|
|
elif opts.formsets is None and opts.exclude_formsets is None:
|
||
|
|
# neither formsets nor exclude_formsets has been specified - no formsets at all
|
||
|
|
continue
|
||
|
|
|
||
|
|
try:
|
||
|
|
widgets = opts.widgets.get(rel_name)
|
||
|
|
except AttributeError: # thrown if opts.widgets is None
|
||
|
|
widgets = None
|
||
|
|
|
||
|
|
kwargs = {
|
||
|
|
'extra': cls.extra_form_count,
|
||
|
|
'form': cls.child_form(),
|
||
|
|
'formfield_callback': formfield_callback,
|
||
|
|
'fk_name': rel.field.name,
|
||
|
|
'widgets': widgets,
|
||
|
|
'formset_name': rel_name
|
||
|
|
}
|
||
|
|
|
||
|
|
# see if opts.formsets looks like a dict; if so, allow the value
|
||
|
|
# to override kwargs
|
||
|
|
try:
|
||
|
|
kwargs.update(opts.formsets.get(rel_name))
|
||
|
|
except AttributeError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
formset_name = kwargs.pop('formset_name')
|
||
|
|
formset = childformset_factory(opts.model, rel.field.model, **kwargs)
|
||
|
|
formsets[formset_name] = formset
|
||
|
|
|
||
|
|
new_class.formsets = formsets
|
||
|
|
|
||
|
|
return new_class
|
||
|
|
|
||
|
|
|
||
|
|
class ClusterForm(ModelForm, metaclass=ClusterFormMetaclass):
|
||
|
|
def __init__(self, data=None, files=None, instance=None, prefix=None, **kwargs):
|
||
|
|
super().__init__(data, files, instance=instance, prefix=prefix, **kwargs)
|
||
|
|
|
||
|
|
self.formsets = {}
|
||
|
|
for rel_name, formset_class in self.__class__.formsets.items():
|
||
|
|
if prefix:
|
||
|
|
formset_prefix = "%s-%s" % (prefix, rel_name)
|
||
|
|
else:
|
||
|
|
formset_prefix = rel_name
|
||
|
|
|
||
|
|
child_form_kwargs = {}
|
||
|
|
if formset_class.inherit_kwargs:
|
||
|
|
for kwarg_name in formset_class.inherit_kwargs:
|
||
|
|
child_form_kwargs[kwarg_name] = getattr(self, kwarg_name, None)
|
||
|
|
|
||
|
|
self.formsets[rel_name] = formset_class(
|
||
|
|
data, files, instance=instance, prefix=formset_prefix, form_kwargs=child_form_kwargs
|
||
|
|
)
|
||
|
|
|
||
|
|
def as_p(self):
|
||
|
|
form_as_p = super().as_p()
|
||
|
|
return form_as_p + format_html_join('', '{}', [(formset.as_p(),) for formset in self.formsets.values()])
|
||
|
|
|
||
|
|
def is_valid(self):
|
||
|
|
form_is_valid = super().is_valid()
|
||
|
|
formsets_are_valid = all(formset.is_valid() for formset in self.formsets.values())
|
||
|
|
return form_is_valid and formsets_are_valid
|
||
|
|
|
||
|
|
def is_multipart(self):
|
||
|
|
return (
|
||
|
|
super().is_multipart()
|
||
|
|
or any(formset.is_multipart() for formset in self.formsets.values())
|
||
|
|
)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def media(self):
|
||
|
|
media = super().media
|
||
|
|
for formset in self.formsets.values():
|
||
|
|
media = media + formset.media
|
||
|
|
return media
|
||
|
|
|
||
|
|
def save(self, commit=True):
|
||
|
|
# do we have any fields that expect us to call save_m2m immediately?
|
||
|
|
save_m2m_now = False
|
||
|
|
exclude = self._meta.exclude
|
||
|
|
fields = self._meta.fields
|
||
|
|
|
||
|
|
for f in self.instance._meta.get_fields():
|
||
|
|
if fields and f.name not in fields:
|
||
|
|
continue
|
||
|
|
if exclude and f.name in exclude:
|
||
|
|
continue
|
||
|
|
if getattr(f, '_need_commit_after_assignment', False):
|
||
|
|
save_m2m_now = True
|
||
|
|
break
|
||
|
|
|
||
|
|
instance = super().save(commit=(commit and not save_m2m_now))
|
||
|
|
|
||
|
|
# The M2M-like fields designed for use with ClusterForm (currently
|
||
|
|
# ParentalManyToManyField and ClusterTaggableManager) will manage their own in-memory
|
||
|
|
# relations, and not immediately write to the database when we assign to them.
|
||
|
|
# For these fields (identified by the _need_commit_after_assignment
|
||
|
|
# flag), save_m2m() is a safe operation that does not affect the database and is thus
|
||
|
|
# valid for commit=False. In the commit=True case, committing to the database happens
|
||
|
|
# in the subsequent instance.save (so this needs to happen after save_m2m to ensure
|
||
|
|
# we have the updated relation data in place).
|
||
|
|
|
||
|
|
# For annoying legacy reasons we sometimes need to accommodate 'classic' M2M fields
|
||
|
|
# (particularly taggit.TaggableManager) within ClusterForm. These fields
|
||
|
|
# generally do require our instance to exist in the database at the point we call
|
||
|
|
# save_m2m() - for this reason, we only proceed with the customisation described above
|
||
|
|
# (i.e. postpone the instance.save() operation until after save_m2m) if there's a
|
||
|
|
# _need_commit_after_assignment field on the form that demands it.
|
||
|
|
|
||
|
|
if save_m2m_now:
|
||
|
|
self.save_m2m()
|
||
|
|
|
||
|
|
if commit:
|
||
|
|
instance.save()
|
||
|
|
|
||
|
|
for formset in self.formsets.values():
|
||
|
|
formset.instance = instance
|
||
|
|
formset.save(commit=commit)
|
||
|
|
return instance
|
||
|
|
|
||
|
|
def has_changed(self):
|
||
|
|
"""Return True if data differs from initial."""
|
||
|
|
|
||
|
|
# Need to recurse over nested formsets so that the form is saved if there are changes
|
||
|
|
# to child forms but not the parent
|
||
|
|
if self.formsets:
|
||
|
|
for formset in self.formsets.values():
|
||
|
|
for form in formset.forms:
|
||
|
|
if form.has_changed():
|
||
|
|
return True
|
||
|
|
return bool(self.changed_data)
|
||
|
|
|
||
|
|
|
||
|
|
def clusterform_factory(model, form=ClusterForm, **kwargs):
|
||
|
|
# Same as Django's modelform_factory, but arbitrary kwargs are accepted and passed on to the
|
||
|
|
# Meta class.
|
||
|
|
|
||
|
|
# Build up a list of attributes that the Meta object will have.
|
||
|
|
meta_class_attrs = kwargs
|
||
|
|
meta_class_attrs["model"] = model
|
||
|
|
|
||
|
|
# If parent form class already has an inner Meta, the Meta we're
|
||
|
|
# creating needs to inherit from the parent's inner meta.
|
||
|
|
bases = (form.Meta,) if hasattr(form, "Meta") else ()
|
||
|
|
Meta = type("Meta", bases, meta_class_attrs)
|
||
|
|
formfield_callback = meta_class_attrs.get('formfield_callback')
|
||
|
|
if formfield_callback:
|
||
|
|
Meta.formfield_callback = staticmethod(formfield_callback)
|
||
|
|
# Give this new form class a reasonable name.
|
||
|
|
class_name = model.__name__ + "Form"
|
||
|
|
|
||
|
|
# Class attributes for the new form class.
|
||
|
|
form_class_attrs = {"Meta": Meta, "formfield_callback": formfield_callback}
|
||
|
|
|
||
|
|
# Instantiate type(form) in order to use the same metaclass as form.
|
||
|
|
return type(form)(class_name, (form,), form_class_attrs)
|