diff --git a/polymorphic/admin/__init__.py b/polymorphic/admin/__init__.py index fe0c31c..60f9f8a 100644 --- a/polymorphic/admin/__init__.py +++ b/polymorphic/admin/__init__.py @@ -4,12 +4,44 @@ ModelAdmin code to display polymorphic models. The admin consists of a parent admin (which shows in the admin with a list), and a child admin (which is used internally to show the edit/delete dialog). """ +# Admins for the regular models from .parentadmin import PolymorphicParentModelAdmin from .childadmin import PolymorphicChildModelAdmin + +# Utils from .forms import PolymorphicModelChoiceForm from .filters import PolymorphicChildModelFilter -__all__ = ( - 'PolymorphicModelChoiceForm', 'PolymorphicParentModelAdmin', - 'PolymorphicChildModelAdmin', 'PolymorphicChildModelFilter' +# Inlines +from .inlines import ( + PolymorphicParentInlineModelAdmin, + PolymorphicChildInlineModelAdmin, +) + +# Helpers for the inlines +from .helpers import ( + InlinePolymorphicAdminForm, + InlinePolymorphicAdminFormSet, + PolymorphicInlineSupportMixin, # mixin for the regular model admin! +) + +# Expose generic admin features too. There is no need to split those +# as the admin already relies on contenttypes. +from .generic import ( + PolymorphicParentGenericInlineModelAdmin, + PolymorphicChildGenericInlineModelAdmin, +) + +__all__ = ( + 'PolymorphicParentModelAdmin', + 'PolymorphicChildModelAdmin', + 'PolymorphicModelChoiceForm', + 'PolymorphicChildModelFilter', + 'InlinePolymorphicAdminForm', + 'InlinePolymorphicAdminFormSet', + 'PolymorphicInlineSupportMixin', + 'PolymorphicParentInlineModelAdmin', + 'PolymorphicChildInlineModelAdmin', + 'PolymorphicParentGenericInlineModelAdmin', + 'PolymorphicChildGenericInlineModelAdmin', ) diff --git a/polymorphic/admin/generic.py b/polymorphic/admin/generic.py new file mode 100644 index 0000000..479a17e --- /dev/null +++ b/polymorphic/admin/generic.py @@ -0,0 +1,55 @@ +from django.contrib.contenttypes.admin import GenericInlineModelAdmin +from django.contrib.contenttypes.models import ContentType +from django.utils.functional import cached_property + +from polymorphic.formsets import polymorphic_child_forms_factory, BasePolymorphicGenericInlineFormSet, PolymorphicGenericFormSetChild +from .inlines import PolymorphicParentInlineModelAdmin, PolymorphicChildInlineModelAdmin + + +class PolymorphicParentGenericInlineModelAdmin(PolymorphicParentInlineModelAdmin, GenericInlineModelAdmin): + """ + Variation for inlines based on generic foreign keys. + """ + formset = BasePolymorphicGenericInlineFormSet + + def get_formset(self, request, obj=None, **kwargs): + """ + Construct the generic inline formset class. + """ + # Construct the FormSet class. This is almost the same as parent version, + # except that a different super is called so generic_inlineformset_factory() is used. + # NOTE that generic_inlineformset_factory() also makes sure the GFK fields are excluded in the form. + FormSet = GenericInlineModelAdmin.get_formset(self, request, obj=obj, **kwargs) + + FormSet.child_forms = polymorphic_child_forms_factory( + formset_children=self.get_formset_children(request, obj=obj) + ) + return FormSet + + +class PolymorphicChildGenericInlineModelAdmin(PolymorphicChildInlineModelAdmin): + """ + Variation for generic inlines. + """ + # Make sure that the GFK fields are excluded from the child forms + formset_child = PolymorphicGenericFormSetChild + ct_field = "content_type" + ct_fk_field = "object_id" + + @cached_property + def content_type(self): + """ + Expose the ContentType that the child relates to. + This can be used for the ``polymorphic_ctype`` field. + """ + return ContentType.objects.get_for_model(self.model) + + def get_formset_child(self, request, obj=None, **kwargs): + # Similar to GenericInlineModelAdmin.get_formset(), + # make sure the GFK is automatically excluded from the form + defaults = { + "ct_field": self.ct_field, + "fk_field": self.ct_fk_field, + } + defaults.update(kwargs) + return super(PolymorphicChildGenericInlineModelAdmin, self).get_formset_child(request, obj=obj, **defaults) diff --git a/polymorphic/admin/helpers.py b/polymorphic/admin/helpers.py new file mode 100644 index 0000000..75822e3 --- /dev/null +++ b/polymorphic/admin/helpers.py @@ -0,0 +1,95 @@ +""" +Rendering utils for admin forms; + +This makes sure that admin fieldsets/layout settings are exported to the template. +""" +from django.contrib.admin.helpers import InlineAdminFormSet, InlineAdminForm + +from ..formsets import BasePolymorphicModelFormSet + + +class InlinePolymorphicAdminForm(InlineAdminForm): + """ + Expose the admin configuration for a form + """ + pass + + +class InlinePolymorphicAdminFormSet(InlineAdminFormSet): + """ + Internally used class to expose the formset in the template. + """ + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop('request', None) # Assigned later via PolymorphicInlineSupportMixin later. + self.obj = kwargs.pop('obj', None) + super(InlinePolymorphicAdminFormSet, self).__init__(*args, **kwargs) + + def __iter__(self): + """ + Output all forms using the proper subtype settings. + """ + for form, original in zip(self.formset.initial_forms, self.formset.get_queryset()): + # Output the form + model = original.get_real_concrete_instance_class() + child_inline = self.opts.get_child_inline_instance(model) + view_on_site_url = self.opts.get_view_on_site_url(original) + + yield InlinePolymorphicAdminForm( + formset=self.formset, + form=form, + fieldsets=self.get_child_fieldsets(child_inline), + prepopulated_fields=self.get_child_prepopulated_fields(child_inline), + original=original, + readonly_fields=self.get_child_readonly_fields(child_inline), + model_admin=child_inline, + view_on_site_url=view_on_site_url + ) + + # Extra rows, and empty prefixed forms. + for form in self.formset.extra_forms + self.formset.empty_forms: + model = form._meta.model + child_inline = self.opts.get_child_inline_instance(model) + yield InlinePolymorphicAdminForm( + formset=self.formset, + form=form, + fieldsets=self.get_child_fieldsets(child_inline), + prepopulated_fields=self.get_child_prepopulated_fields(child_inline), + original=None, + readonly_fields=self.get_child_readonly_fields(child_inline), + model_admin=child_inline, + ) + + def get_child_fieldsets(self, child_inline): + return list(child_inline.get_fieldsets(self.request, self.obj) or ()) + + def get_child_readonly_fields(self, child_inline): + return list(child_inline.get_readonly_fields(self.request, self.obj)) + + def get_child_prepopulated_fields(self, child_inline): + fields = self.prepopulated_fields.copy() + fields.update(child_inline.get_prepopulated_fields(self.request, self.obj)) + return fields + + +class PolymorphicInlineSupportMixin(object): + """ + A Mixin to add to the regular admin, so it can work with our polymorphic inlines. + """ + + def get_inline_formsets(self, request, formsets, inline_instances, obj=None): + """ + Overwritten version to produce the proper admin wrapping for the + polymorphic inline formset. This fixes the media and form appearance + of the inline polymorphic models. + """ + inline_admin_formsets = super(PolymorphicInlineSupportMixin, self).get_inline_formsets( + request, formsets, inline_instances, obj=obj) + + for admin_formset in inline_admin_formsets: + if isinstance(admin_formset.formset, BasePolymorphicModelFormSet): + # Downcast the admin + admin_formset.__class__ = InlinePolymorphicAdminFormSet + admin_formset.request = request + admin_formset.obj = obj + return inline_admin_formsets diff --git a/polymorphic/admin/inlines.py b/polymorphic/admin/inlines.py new file mode 100644 index 0000000..11fcc53 --- /dev/null +++ b/polymorphic/admin/inlines.py @@ -0,0 +1,214 @@ +""" +Django Admin support for polymorphic inlines. + +Each row in the inline can correspond with a different subclass. +""" +from functools import partial + +from django.contrib.admin.options import InlineModelAdmin +from django.contrib.admin.utils import flatten_fieldsets +from django.forms import Media + +from polymorphic.formsets import polymorphic_child_forms_factory, BasePolymorphicInlineFormSet, PolymorphicFormSetChild +from polymorphic.formsets.utils import add_media + + +class PolymorphicParentInlineModelAdmin(InlineModelAdmin): + """ + A polymorphic inline, where each formset row can be a different form. + + Note that + + * Permissions are only checked on the base model. + * The child inlines can't override the base model fields, only this parent inline can do that. + * Child formset media is not yet processed. + """ + formset = BasePolymorphicInlineFormSet + + #: The extra forms to show + #: By default there are no 'extra' forms as the desired type is unknown. + #: Instead, add each new item using JavaScript that first offers a type-selection. + extra = 0 + + #: Inlines for all model sub types that can be displayed in this inline. + #: Each row is a :class:`PolymorphicChildInlineModelAdmin` + child_inlines = () + + def __init__(self, parent_model, admin_site): + super(PolymorphicParentInlineModelAdmin, self).__init__(parent_model, admin_site) + + # While the inline is created per request, the 'request' object is not known here. + # Hence, creating all child inlines unconditionally, without checking permissions. + self.child_inline_instances = self.get_child_inline_instances() + + # Create a lookup table + self._child_inlines_lookup = {} + for child_inline in self.child_inline_instances: + self._child_inlines_lookup[child_inline.model] = child_inline + + def get_child_inline_instances(self): + """ + :rtype List[PolymorphicChildInlineModelAdmin] + """ + instances = [] + for ChildInlineType in self.child_inlines: + instances.append(ChildInlineType(parent_inline=self)) + return instances + + def get_child_inline_instance(self, model): + """ + Find the child inline for a given model. + + :rtype: PolymorphicChildInlineModelAdmin + """ + try: + return self._child_inlines_lookup[model] + except KeyError: + raise ValueError("Model '{0}' not found in child_inlines".format(model.__name__)) + + def get_formset(self, request, obj=None, **kwargs): + """ + Construct the inline formset class. + + This passes all class attributes to the formset. + + :rtype: type + """ + # Construct the FormSet class + FormSet = super(PolymorphicParentInlineModelAdmin, self).get_formset(request, obj=obj, **kwargs) + + # Instead of completely redefining super().get_formset(), we use + # the regular inlineformset_factory(), and amend that with our extra bits. + # This is identical to what polymorphic_inlineformset_factory() does. + FormSet.child_forms = polymorphic_child_forms_factory( + formset_children=self.get_formset_children(request, obj=obj) + ) + return FormSet + + def get_formset_children(self, request, obj=None): + """ + The formset 'children' provide the details for all child models that are part of this formset. + It provides a stripped version of the modelform/formset factory methods. + """ + formset_children = [] + for child_inline in self.child_inline_instances: + # TODO: the children can be limited here per request based on permissions. + formset_children.append(child_inline.get_formset_child(request, obj=obj)) + return formset_children + + def get_fieldsets(self, request, obj=None): + """ + Hook for specifying fieldsets. + """ + if self.fieldsets: + return self.fieldsets + else: + return [] # Avoid exposing fields to the child + + def get_fields(self, request, obj=None): + if self.fields: + return self.fields + else: + return [] # Avoid exposing fields to the child + + @property + def media(self): + # The media of the inline focuses on the admin settings, + # whether to expose the scripts for filter_horizontal etc.. + # The admin helper exposes the inline + formset media. + base_media = super(PolymorphicParentInlineModelAdmin, self).media + all_media = Media() + add_media(all_media, base_media) + + # Add all media of the child inline instances + for child_instance in self.child_inline_instances: + child_media = child_instance.media + + # Avoid adding the same media object again and again + if child_media._css != base_media._css and child_media._js != base_media._js: + add_media(all_media, child_media) + + return all_media + + +class PolymorphicChildInlineModelAdmin(InlineModelAdmin): + """ + The child inline; which allows configuring the admin options + for the child appearance. + + Note that not all options will be honored by the parent, notably the formset options: + * :attr:`extra` + * :attr:`min_num` + * :attr:`max_num` + + The model form options however, will all be read. + """ + formset_child = PolymorphicFormSetChild + extra = 0 # TODO: currently unused for the children. + + def __init__(self, parent_inline): + self.parent_inline = parent_inline + super(PolymorphicChildInlineModelAdmin, self).__init__(parent_inline.parent_model, parent_inline.admin_site) + + def get_formset(self, request, obj=None, **kwargs): + # The child inline is only used to construct the form, + # and allow to override the form field attributes. + # The formset is created by the parent inline. + raise RuntimeError("The child get_formset() is not used.") + + def get_fields(self, request, obj=None): + if self.fields: + return self.fields + + # Standard Django logic, use the form to determine the fields. + # The form needs to pass through all factory logic so all 'excludes' are set as well. + # Default Django does: form = self.get_formset(request, obj, fields=None).form + # Use 'fields=None' avoids recursion in the field autodetection. + form = self.get_formset_child(request, obj, fields=None).get_form() + return list(form.base_fields) + list(self.get_readonly_fields(request, obj)) + + def get_formset_child(self, request, obj=None, **kwargs): + """ + Return the formset child that the parent inline can use to represent us. + + :rtype: PolymorphicFormSetChild + """ + # Similar to the normal get_formset(), the caller may pass fields to override the defaults settings + # in the inline. In Django's GenericInlineModelAdmin.get_formset() this is also used in the same way, + # to make sure the 'exclude' also contains the GFK fields. + # + # Hence this code is almost identical to InlineModelAdmin.get_formset() + # and GenericInlineModelAdmin.get_formset() + # + # Transfer the local inline attributes to the formset child, + # this allows overriding settings. + if 'fields' in kwargs: + fields = kwargs.pop('fields') + else: + fields = flatten_fieldsets(self.get_fieldsets(request, obj)) + + if self.exclude is None: + exclude = [] + else: + exclude = list(self.exclude) + + exclude.extend(self.get_readonly_fields(request, obj)) + + if self.exclude is None and hasattr(self.form, '_meta') and self.form._meta.exclude: + # Take the custom ModelForm's Meta.exclude into account only if the + # InlineModelAdmin doesn't define its own. + exclude.extend(self.form._meta.exclude) + + #can_delete = self.can_delete and self.has_delete_permission(request, obj) + defaults = { + "form": self.form, + "fields": fields, + "exclude": exclude or None, + "formfield_callback": partial(self.formfield_for_dbfield, request=request), + } + defaults.update(kwargs) + + # This goes through the same logic that get_formset() calls + # by passing the inline class attributes to modelform_factory() + FormSetChildClass = self.formset_child + return FormSetChildClass(self.model, **defaults) diff --git a/polymorphic/formsets/__init__.py b/polymorphic/formsets/__init__.py new file mode 100644 index 0000000..4fb4114 --- /dev/null +++ b/polymorphic/formsets/__init__.py @@ -0,0 +1,51 @@ +""" +Polymorphic formsets support. + +This allows creating formsets where each row can be a different form type. +The logic of the formsets work similar to the standard Django formsets; +there are factory methods to construct the classes with the proper form settings. + +The "parent" formset hosts the entire model and their child model. +For every child type, there is an :class:`PolymorphicFormSetChild` instance +that describes how to display and construct the child. +It's parameters are very similar to the parent's factory method. + +See: +* :func:`polymorphic_inlineformset_factory` +* :class:`PolymorphicFormSetChild` + +The internal machinery can be used to extend the formset classes. This includes: +* :class:`BasePolymorphicModelFormSet` +* :class:`BasePolymorphicInlineFormSet` +* :func:`polymorphic_child_forms_factory` + +For generic relations, a similar set is available: +* :class:`BasePolymorphicGenericInlineFormSet` +* :class:`PolymorphicGenericFormSetChild` +* :func:`polymorphic_generic_inlineformset_factory` + +""" +from .models import ( + BasePolymorphicModelFormSet, + BasePolymorphicInlineFormSet, + PolymorphicFormSetChild, + polymorphic_inlineformset_factory, + polymorphic_child_forms_factory, +) +from .generic import ( + # Can import generic here, as polymorphic already depends on the 'contenttypes' app. + BasePolymorphicGenericInlineFormSet, + PolymorphicGenericFormSetChild, + polymorphic_generic_inlineformset_factory, +) + +__all__ = ( + 'BasePolymorphicModelFormSet', + 'BasePolymorphicInlineFormSet', + 'BasePolymorphicGenericInlineFormSet', + 'PolymorphicFormSetChild', + 'PolymorphicGenericFormSetChild', + 'polymorphic_inlineformset_factory', + 'polymorphic_generic_inlineformset_factory', + 'polymorphic_child_forms_factory', +) diff --git a/polymorphic/formsets/generic.py b/polymorphic/formsets/generic.py new file mode 100644 index 0000000..0872906 --- /dev/null +++ b/polymorphic/formsets/generic.py @@ -0,0 +1,112 @@ +import django +from django.contrib.contenttypes.forms import BaseGenericInlineFormSet, generic_inlineformset_factory +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.forms.models import ModelForm + +from .models import BasePolymorphicModelFormSet, polymorphic_child_forms_factory, PolymorphicFormSetChild + + +class PolymorphicGenericFormSetChild(PolymorphicFormSetChild): + """ + Formset child for generic inlines + """ + def __init__(self, *args, **kwargs): + self.ct_field = kwargs.pop('ct_field', 'content_type') + self.fk_field = kwargs.pop('fk_field', 'object_id') + super(PolymorphicGenericFormSetChild, self).__init__(*args, **kwargs) + + def get_form(self, ct_field="content_type", fk_field="object_id", **kwargs): + """ + Construct the form class for the formset child. + """ + exclude = list(self.exclude) + extra_exclude = kwargs.pop('extra_exclude', None) + if extra_exclude: + exclude += list(extra_exclude) + + # Make sure the GFK fields are excluded by default + # This is similar to what generic_inlineformset_factory() does + # if there is no field called `ct_field` let the exception propagate + opts = self.model._meta + ct_field = opts.get_field(self.ct_field) + + if django.VERSION >= (1, 9): + if not isinstance(ct_field, models.ForeignKey) or ct_field.remote_field.model != ContentType: + raise Exception("fk_name '%s' is not a ForeignKey to ContentType" % ct_field) + else: + if not isinstance(ct_field, models.ForeignKey) or ct_field.rel.to != ContentType: + raise Exception("fk_name '%s' is not a ForeignKey to ContentType" % ct_field) + + fk_field = opts.get_field(self.fk_field) # let the exception propagate + exclude.extend([ct_field.name, fk_field.name]) + kwargs['exclude'] = exclude + + return super(PolymorphicGenericFormSetChild, self).get_form(**kwargs) + + +class BasePolymorphicGenericInlineFormSet(BaseGenericInlineFormSet, BasePolymorphicModelFormSet): + """ + Polymorphic formset variation for inline generic formsets + """ + + +def polymorphic_generic_inlineformset_factory(model, formset_children, form=ModelForm, + formset=BasePolymorphicGenericInlineFormSet, + ct_field="content_type", fk_field="object_id", + # Base form + # TODO: should these fields be removed in favor of creating + # the base form as a formset child too? + fields=None, exclude=None, + extra=1, can_order=False, can_delete=True, + max_num=None, formfield_callback=None, + validate_max=False, for_concrete_model=True, + min_num=None, validate_min=False, child_form_kwargs=None): + """ + Construct the class for a generic inline polymorphic formset. + + All arguments are identical to :func:`~django.contrib.contenttypes.forms.generic_inlineformset_factory`, + with the exception of the ``formset_children`` argument. + + :param formset_children: A list of all child :class:`PolymorphicFormSetChild` objects + that tell the inline how to render the child model types. + :type formset_children: Iterable[PolymorphicFormSetChild] + :rtype: type + """ + kwargs = { + 'model': model, + 'form': form, + 'formfield_callback': formfield_callback, + 'formset': formset, + 'ct_field': ct_field, + 'fk_field': fk_field, + 'extra': extra, + 'can_delete': can_delete, + 'can_order': can_order, + 'fields': fields, + 'exclude': exclude, + 'min_num': min_num, + 'max_num': max_num, + 'validate_min': validate_min, + 'validate_max': validate_max, + 'for_concrete_model': for_concrete_model, + #'localized_fields': localized_fields, + #'labels': labels, + #'help_texts': help_texts, + #'error_messages': error_messages, + #'field_classes': field_classes, + } + if child_form_kwargs is None: + child_form_kwargs = {} + + child_kwargs = { + #'exclude': exclude, + 'ct_field': ct_field, + 'fk_field': fk_field, + } + if child_form_kwargs: + child_kwargs.update(child_form_kwargs) + + FormSet = generic_inlineformset_factory(**kwargs) + FormSet.child_forms = polymorphic_child_forms_factory(formset_children, **child_kwargs) + return FormSet diff --git a/polymorphic/formsets/models.py b/polymorphic/formsets/models.py new file mode 100644 index 0000000..43d0349 --- /dev/null +++ b/polymorphic/formsets/models.py @@ -0,0 +1,305 @@ +from collections import OrderedDict + +import django +from django import forms +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.forms.models import ModelForm, BaseModelFormSet, BaseInlineFormSet, modelform_factory, inlineformset_factory +from django.utils.functional import cached_property +from .utils import add_media + + +class PolymorphicFormSetChild(object): + """ + Metadata to define the inline of a polymorphic child. + Provide this information in the :func:`polymorphic_inlineformset_factory` construction. + """ + def __init__(self, model, form=ModelForm, fields=None, exclude=None, + formfield_callback=None, widgets=None, localized_fields=None, + labels=None, help_texts=None, error_messages=None): + + self.model = model + + # Instead of initializing the form here right away, + # the settings are saved so get_form() can receive additional exclude kwargs. + # This is mostly needed for the generic inline formsets + self._form_base = form + self.fields = fields + self.exclude = exclude + self.formfield_callback = formfield_callback + self.widgets = widgets + self.localized_fields = localized_fields + self.labels = labels + self.help_texts = help_texts + self.error_messages = error_messages + + @cached_property + def content_type(self): + """ + Expose the ContentType that the child relates to. + This can be used for the ``polymorphic_ctype`` field. + """ + return ContentType.objects.get_for_model(self.model) + + def get_form(self, **kwargs): + """ + Construct the form class for the formset child. + """ + # Do what modelformset_factory() / inlineformset_factory() does to the 'form' argument; + # Construct the form with the given ModelFormOptions values + + # Fields can be overwritten. To support the global `polymorphic_child_forms_factory` kwargs, + # that doesn't completely replace all `exclude` settings defined per child type, + # we allow to define things like 'extra_...' fields that are amended to the current child settings. + + exclude = list(self.exclude) + extra_exclude = kwargs.pop('extra_exclude', None) + if extra_exclude: + exclude += list(extra_exclude) + + defaults = { + 'form': self._form_base, + 'formfield_callback': self.formfield_callback, + 'fields': self.fields, + 'exclude': exclude, + #'for_concrete_model': for_concrete_model, + 'localized_fields': self.localized_fields, + 'labels': self.labels, + 'help_texts': self.help_texts, + 'error_messages': self.error_messages, + #'field_classes': field_classes, + } + defaults.update(kwargs) + + return modelform_factory(self.model, **defaults) + + +def polymorphic_child_forms_factory(formset_children, **kwargs): + """ + Construct the forms for the formset children. + """ + child_forms = OrderedDict() + + for formset_child in formset_children: + child_forms[formset_child.model] = formset_child.get_form(**kwargs) + + return child_forms + + +class BasePolymorphicModelFormSet(BaseModelFormSet): + """ + A formset that can produce different forms depending on the object type. + + Note that the 'add' feature is therefore more complex, + as all variations need ot be exposed somewhere. + + When switching existing formsets to the polymorphic formset, + note that the ID field will no longer be named ``model_ptr``, + but just appear as ``id``. + """ + # Assigned by the factory + child_forms = OrderedDict() + + def __init__(self, *args, **kwargs): + super(BasePolymorphicModelFormSet, self).__init__(*args, **kwargs) + self.queryset_data = self.get_queryset() + + def _construct_form(self, i, **kwargs): + """ + Create the form, depending on the model that's behind it. + """ + # BaseModelFormSet logic + 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] + 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 + + # BaseFormSet logic, with custom formset_class + defaults = { + 'auto_id': self.auto_id, + 'prefix': self.add_prefix(i), + 'error_class': self.error_class, + } + if self.is_bound: + defaults['data'] = self.data + defaults['files'] = self.files + if self.initial and 'initial' not in kwargs: + try: + defaults['initial'] = self.initial[i] + except IndexError: + pass + # Allow extra forms to be empty, unless they're part of + # the minimum forms. + if i >= self.initial_form_count() and i >= self.min_num: + defaults['empty_permitted'] = True + defaults.update(kwargs) + + # Need to find the model that will be displayed in this form. + # Hence, peeking in the self.queryset_data beforehand. + if self.is_bound: + if 'instance' in defaults: + # Object is already bound to a model, won't change the content type + model = defaults['instance'].get_real_concrete_instance_class() # respect proxy models + else: + # Extra or empty form, use the provided type. + # Note this completely tru + prefix = defaults['prefix'] + try: + ct_id = int(self.data["{0}-polymorphic_ctype".format(prefix)]) + except (KeyError, ValueError): + raise ValidationError("Formset row {0} has no 'polymorphic_ctype' defined!".format(prefix)) + + model = ContentType.objects.get_for_id(ct_id).model_class() + if model not in self.child_forms: + # Perform basic validation, as we skip the ChoiceField here. + raise ValidationError("Child model type {0} is not part of the formset".format(model)) + else: + if 'instance' in defaults: + model = defaults['instance'].get_real_concrete_instance_class() # respect proxy models + elif 'polymorphic_ctype' in defaults.get('initial', {}): + model = defaults['initial']['polymorphic_ctype'].model_class() + elif i < len(self.queryset_data): + model = self.queryset_data[i].__class__ + else: + # Extra forms, cycle between all types + # TODO: take the 'extra' value of each child formset into account. + total_known = len(self.queryset_data) + child_models = self.child_forms.keys() + model = child_models[(i - total_known) % len(child_models)] + + form_class = self.get_form_class(model) + form = form_class(**defaults) + self.add_fields(form, i) + return form + + def add_fields(self, form, index): + """Add a hidden field for the content type.""" + ct = ContentType.objects.get_for_model(form._meta.model) + choices = [(ct.pk, ct)] # Single choice, existing forms can't change the value. + form.fields['polymorphic_ctype'] = forms.ChoiceField(choices=choices, initial=ct.pk, required=False, widget=forms.HiddenInput) + super(BasePolymorphicModelFormSet, self).add_fields(form, index) + + def get_form_class(self, model): + if not self.child_forms: + raise ImproperlyConfigured("No 'child_forms' defined in {0}".format(self.__class__.__name__)) + return self.child_forms[model] + + def is_multipart(self): + """ + Returns True if the formset needs to be multipart, i.e. it + has FileInput. Otherwise, False. + """ + return any(f.is_multipart() for f in self.empty_forms) + + @property + def media(self): + # Include the media of all form types. + # The form media includes all form widget media + media = forms.Media() + for form in self.empty_forms: + add_media(media, form.media) + return media + + @cached_property + def empty_forms(self): + """ + Return all possible empty forms + """ + forms = [] + for model, form_class in self.child_forms.items(): + if django.VERSION >= (1, 9): + kwargs = self.get_form_kwargs(None) # New Django 1.9 method + else: + kwargs = {} + + form = form_class( + auto_id=self.auto_id, + prefix=self.add_prefix('__prefix__'), + empty_permitted=True, + **kwargs + ) + self.add_fields(form, None) + forms.append(form) + return forms + + @property + def empty_form(self): + # TODO: make an exception when can_add_base is defined? + raise RuntimeError("'empty_form' is not used in polymorphic formsets, use 'empty_forms' instead.") + + +class BasePolymorphicInlineFormSet(BaseInlineFormSet, BasePolymorphicModelFormSet): + """ + Polymorphic formset variation for inline formsets + """ + + def _construct_form(self, i, **kwargs): + return super(BasePolymorphicInlineFormSet, self)._construct_form(i, **kwargs) + + +def polymorphic_inlineformset_factory(parent_model, model, formset_children, + formset=BasePolymorphicInlineFormSet, fk_name=None, + # Base field + # TODO: should these fields be removed in favor of creating + # the base form as a formset child too? + form=ModelForm, + fields=None, exclude=None, extra=1, can_order=False, + can_delete=True, max_num=None, formfield_callback=None, + widgets=None, validate_max=False, localized_fields=None, + labels=None, help_texts=None, error_messages=None, + min_num=None, validate_min=False, field_classes=None, child_form_kwargs=None): + """ + Construct the class for an inline polymorphic formset. + + All arguments are identical to :func:`~django.forms.models.inlineformset_factory`, + with the exception of the ``formset_children`` argument. + + :param formset_children: A list of all child :class:`PolymorphicFormSetChild` objects + that tell the inline how to render the child model types. + :type formset_children: Iterable[PolymorphicFormSetChild] + :rtype: type + """ + kwargs = { + 'parent_model': parent_model, + 'model': model, + 'form': form, + 'formfield_callback': formfield_callback, + 'formset': formset, + 'fk_name': fk_name, + 'extra': extra, + 'can_delete': can_delete, + 'can_order': can_order, + 'fields': fields, + 'exclude': exclude, + 'min_num': min_num, + 'max_num': max_num, + 'widgets': widgets, + 'validate_min': validate_min, + 'validate_max': validate_max, + 'localized_fields': localized_fields, + 'labels': labels, + 'help_texts': help_texts, + 'error_messages': error_messages, + 'field_classes': field_classes, + } + FormSet = inlineformset_factory(**kwargs) + + child_kwargs = { + #'exclude': exclude, + } + if child_form_kwargs: + child_kwargs.update(child_form_kwargs) + + FormSet.child_forms = polymorphic_child_forms_factory(formset_children, **child_kwargs) + return FormSet diff --git a/polymorphic/formsets/utils.py b/polymorphic/formsets/utils.py new file mode 100644 index 0000000..589a3e2 --- /dev/null +++ b/polymorphic/formsets/utils.py @@ -0,0 +1,10 @@ +""" +Internal utils +""" + +def add_media(dest, media): + """ + Optimized version of django.forms.Media.__add__() that doesn't create new objects. + """ + dest.add_css(media._css) + dest.add_js(media._js)