From a07ce7260c2934325da988e00bff05b087a2d531 Mon Sep 17 00:00:00 2001 From: Diederik van der Boor Date: Fri, 10 Jun 2016 14:30:51 +0200 Subject: [PATCH 1/8] Upstream support for polymorphic formsets and and inline models. Originally written in django-fluent-contents to support polymorphic generic inlines; https://github.com/edoburu/django-fluent-contents/commit/72d816b8bb92eee6f97d5b5d1e60203501733f23 --- polymorphic/admin/__init__.py | 38 +++- polymorphic/admin/generic.py | 55 ++++++ polymorphic/admin/helpers.py | 95 ++++++++++ polymorphic/admin/inlines.py | 214 ++++++++++++++++++++++ polymorphic/formsets/__init__.py | 51 ++++++ polymorphic/formsets/generic.py | 112 ++++++++++++ polymorphic/formsets/models.py | 305 +++++++++++++++++++++++++++++++ polymorphic/formsets/utils.py | 10 + 8 files changed, 877 insertions(+), 3 deletions(-) create mode 100644 polymorphic/admin/generic.py create mode 100644 polymorphic/admin/helpers.py create mode 100644 polymorphic/admin/inlines.py create mode 100644 polymorphic/formsets/__init__.py create mode 100644 polymorphic/formsets/generic.py create mode 100644 polymorphic/formsets/models.py create mode 100644 polymorphic/formsets/utils.py 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) From 8c42893abd335686d34729faa08a4904b2c48ef1 Mon Sep 17 00:00:00 2001 From: Diederik van der Boor Date: Mon, 13 Jun 2016 10:18:18 +0200 Subject: [PATCH 2/8] docs start for formset/inline support --- docs/admin.rst | 8 +++++--- docs/formsets.rst | 12 ++++++++++++ docs/index.rst | 1 + polymorphic/admin/childadmin.py | 4 +++- polymorphic/admin/filters.py | 6 ++++++ polymorphic/admin/helpers.py | 13 +++++++++++-- polymorphic/admin/inlines.py | 5 ++--- polymorphic/formsets/models.py | 8 ++++++++ 8 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 docs/formsets.rst diff --git a/docs/admin.rst b/docs/admin.rst index 3a945f5..c06f98c 100644 --- a/docs/admin.rst +++ b/docs/admin.rst @@ -57,10 +57,12 @@ use the ``base_form`` and ``base_fieldsets`` instead. The ``PolymorphicChildMode automatically detect the additional fields that the child model has, display those in a separate fieldset. -Polymorphic Inlines -------------------- +Using polymorphic models in standard inlines +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To add a polymorphic child model as an Inline for another model, add a field to the inline's readonly_fields list formed by the lowercased name of the polymorphic parent model with the string "_ptr" appended to it. Otherwise, trying to save that model in the admin will raise an AttributeError with the message "can't set attribute". +To add a polymorphic child model as an Inline for another model, add a field to the inline's ``readonly_fields`` list +formed by the lowercased name of the polymorphic parent model with the string ``_ptr`` appended to it. +Otherwise, trying to save that model in the admin will raise an AttributeError with the message "can't set attribute". .. _admin-example: diff --git a/docs/formsets.rst b/docs/formsets.rst new file mode 100644 index 0000000..ea4fa99 --- /dev/null +++ b/docs/formsets.rst @@ -0,0 +1,12 @@ +Formsets +======== + +Polymorphic models can be used in formsets. + +Use the :func:`polymorphic.formsets.polymorphic_inlineformset_factory` function to generate the formset. +As extra parameter, the factory needs to know how to display the child models. +Provide a list of :class:`polymorphic.formsets.PolymorphicFormSetChild` objects for this + +.. code-block:: python + + from polymorphic.formsets import polymorphic_child_forms_factory diff --git a/docs/index.rst b/docs/index.rst index 67b5f69..ece7673 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -49,6 +49,7 @@ Getting started quickstart admin + formsets performance Advanced topics diff --git a/polymorphic/admin/childadmin.py b/polymorphic/admin/childadmin.py index 63cda0f..468d3d6 100644 --- a/polymorphic/admin/childadmin.py +++ b/polymorphic/admin/childadmin.py @@ -6,8 +6,10 @@ from django.core.urlresolvers import resolve from django.utils import six from django.utils.translation import ugettext_lazy as _ +from .helpers import PolymorphicInlineSupportMixin -class PolymorphicChildModelAdmin(admin.ModelAdmin): + +class PolymorphicChildModelAdmin(PolymorphicInlineSupportMixin, admin.ModelAdmin): """ The *optional* base class for the admin interface of derived models. diff --git a/polymorphic/admin/filters.py b/polymorphic/admin/filters.py index 16b043b..82b4964 100644 --- a/polymorphic/admin/filters.py +++ b/polymorphic/admin/filters.py @@ -7,6 +7,12 @@ class PolymorphicChildModelFilter(admin.SimpleListFilter): """ An admin list filter for the PolymorphicParentModelAdmin which enables filtering by its child models. + + This can be used in the parent admin: + + .. code-block:: python + + list_filter = (PolymorphicChildModelFilter,) """ title = _('Type') parameter_name = 'polymorphic_ctype' diff --git a/polymorphic/admin/helpers.py b/polymorphic/admin/helpers.py index 75822e3..89e9da4 100644 --- a/polymorphic/admin/helpers.py +++ b/polymorphic/admin/helpers.py @@ -5,7 +5,7 @@ This makes sure that admin fieldsets/layout settings are exported to the templat """ from django.contrib.admin.helpers import InlineAdminFormSet, InlineAdminForm -from ..formsets import BasePolymorphicModelFormSet +from polymorphic.formsets import BasePolymorphicModelFormSet class InlinePolymorphicAdminForm(InlineAdminForm): @@ -75,6 +75,14 @@ class InlinePolymorphicAdminFormSet(InlineAdminFormSet): class PolymorphicInlineSupportMixin(object): """ A Mixin to add to the regular admin, so it can work with our polymorphic inlines. + + This mixin needs to be included in the admin that hosts the ``inlines``. + It makes sure the generated admin forms have different fieldsets/fields + depending on the polymorphic type of the form instance. + + This is achieved by overwriting :func:`get_inline_formsets` to return + an :class:`InlinePolymorphicAdminFormSet` instead of a standard Django + :class:`~django.contrib.admin.helpers.InlineAdminFormSet` for the polymorphic formsets. """ def get_inline_formsets(self, request, formsets, inline_instances, obj=None): @@ -88,7 +96,8 @@ class PolymorphicInlineSupportMixin(object): for admin_formset in inline_admin_formsets: if isinstance(admin_formset.formset, BasePolymorphicModelFormSet): - # Downcast the admin + # This is a polymorphic formset, which belongs to our inline. + # Downcast the admin wrapper that generates the form fields. admin_formset.__class__ = InlinePolymorphicAdminFormSet admin_formset.request = request admin_formset.obj = obj diff --git a/polymorphic/admin/inlines.py b/polymorphic/admin/inlines.py index 11fcc53..7817cf2 100644 --- a/polymorphic/admin/inlines.py +++ b/polymorphic/admin/inlines.py @@ -17,11 +17,10 @@ class PolymorphicParentInlineModelAdmin(InlineModelAdmin): """ A polymorphic inline, where each formset row can be a different form. - Note that + 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 @@ -79,7 +78,7 @@ class PolymorphicParentInlineModelAdmin(InlineModelAdmin): # 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. + # This code line is the essence of what polymorphic_inlineformset_factory() does. FormSet.child_forms = polymorphic_child_forms_factory( formset_children=self.get_formset_children(request, obj=obj) ) diff --git a/polymorphic/formsets/models.py b/polymorphic/formsets/models.py index 43d0349..4248ab4 100644 --- a/polymorphic/formsets/models.py +++ b/polymorphic/formsets/models.py @@ -14,6 +14,7 @@ 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): @@ -77,6 +78,9 @@ class PolymorphicFormSetChild(object): def polymorphic_child_forms_factory(formset_children, **kwargs): """ Construct the forms for the formset children. + This is mostly used internally, and rarely needs to be used by external projects. + When using the factory methods (:func:`polymorphic_inlineformset_factory`), + this feature is called already for you. """ child_forms = OrderedDict() @@ -97,6 +101,7 @@ class BasePolymorphicModelFormSet(BaseModelFormSet): note that the ID field will no longer be named ``model_ptr``, but just appear as ``id``. """ + # Assigned by the factory child_forms = OrderedDict() @@ -191,6 +196,9 @@ class BasePolymorphicModelFormSet(BaseModelFormSet): super(BasePolymorphicModelFormSet, self).add_fields(form, index) def get_form_class(self, model): + """ + Return the proper form class for the given model. + """ if not self.child_forms: raise ImproperlyConfigured("No 'child_forms' defined in {0}".format(self.__class__.__name__)) return self.child_forms[model] From 1f0ddd8436026bcbaf970e50cbc7d471bb2b83c8 Mon Sep 17 00:00:00 2001 From: Diederik van der Boor Date: Tue, 9 Aug 2016 01:11:05 +0200 Subject: [PATCH 3/8] Rename inline/formset classes to avoid more confusion. AbstractSingletonProxyFactoryBean danger luked here... Added the Stacked...Inline for clarity too. --- polymorphic/admin/__init__.py | 23 ++--- polymorphic/admin/generic.py | 64 ++++++------ polymorphic/admin/helpers.py | 18 ++-- polymorphic/admin/inlines.py | 171 ++++++++++++++++--------------- polymorphic/formsets/__init__.py | 12 +-- polymorphic/formsets/generic.py | 12 +-- 6 files changed, 157 insertions(+), 143 deletions(-) diff --git a/polymorphic/admin/__init__.py b/polymorphic/admin/__init__.py index 60f9f8a..e067b62 100644 --- a/polymorphic/admin/__init__.py +++ b/polymorphic/admin/__init__.py @@ -14,22 +14,22 @@ from .filters import PolymorphicChildModelFilter # Inlines from .inlines import ( - PolymorphicParentInlineModelAdmin, - PolymorphicChildInlineModelAdmin, + PolymorphicInlineModelAdmin, # base class + StackedPolymorphicInline, # stacked inline ) # Helpers for the inlines from .helpers import ( - InlinePolymorphicAdminForm, - InlinePolymorphicAdminFormSet, + PolymorphicInlineAdminForm, + PolymorphicInlineAdminFormSet, 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, + GenericPolymorphicInlineModelAdmin, # base class + GenericStackedPolymorphicInline, # stacked inline ) __all__ = ( @@ -37,11 +37,10 @@ __all__ = ( 'PolymorphicChildModelAdmin', 'PolymorphicModelChoiceForm', 'PolymorphicChildModelFilter', - 'InlinePolymorphicAdminForm', - 'InlinePolymorphicAdminFormSet', + 'PolymorphicInlineAdminForm', + 'PolymorphicInlineAdminFormSet', 'PolymorphicInlineSupportMixin', - 'PolymorphicParentInlineModelAdmin', - 'PolymorphicChildInlineModelAdmin', - 'PolymorphicParentGenericInlineModelAdmin', - 'PolymorphicChildGenericInlineModelAdmin', + 'PolymorphicInlineModelAdmin', + 'GenericPolymorphicInlineModelAdmin', + 'GenericStackedPolymorphicInline', ) diff --git a/polymorphic/admin/generic.py b/polymorphic/admin/generic.py index 479a17e..747be67 100644 --- a/polymorphic/admin/generic.py +++ b/polymorphic/admin/generic.py @@ -2,15 +2,15 @@ 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 +from polymorphic.formsets import polymorphic_child_forms_factory, BaseGenericPolymorphicInlineFormSet, GenericPolymorphicFormSetChild +from .inlines import PolymorphicInlineModelAdmin -class PolymorphicParentGenericInlineModelAdmin(PolymorphicParentInlineModelAdmin, GenericInlineModelAdmin): +class GenericPolymorphicInlineModelAdmin(PolymorphicInlineModelAdmin, GenericInlineModelAdmin): """ - Variation for inlines based on generic foreign keys. + Base class for variation of inlines based on generic foreign keys. """ - formset = BasePolymorphicGenericInlineFormSet + formset = BaseGenericPolymorphicInlineFormSet def get_formset(self, request, obj=None, **kwargs): """ @@ -26,30 +26,36 @@ class PolymorphicParentGenericInlineModelAdmin(PolymorphicParentInlineModelAdmin ) 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): + class Child(PolymorphicInlineModelAdmin.Child): """ - Expose the ContentType that the child relates to. - This can be used for the ``polymorphic_ctype`` field. + Variation for generic inlines. """ - return ContentType.objects.get_for_model(self.model) + # Make sure that the GFK fields are excluded from the child forms + formset_child = GenericPolymorphicFormSetChild + ct_field = "content_type" + ct_fk_field = "object_id" - 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) + @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(GenericPolymorphicInlineModelAdmin.Child, self).get_formset_child(request, obj=obj, **defaults) + + +class GenericStackedPolymorphicInline(GenericPolymorphicInlineModelAdmin): + """ + The stacked layout for generic inlines. + """ + template = 'admin/polymorphic/edit_inline/stacked.html' diff --git a/polymorphic/admin/helpers.py b/polymorphic/admin/helpers.py index 89e9da4..32698c9 100644 --- a/polymorphic/admin/helpers.py +++ b/polymorphic/admin/helpers.py @@ -3,19 +3,19 @@ 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 +import django +from django.contrib.admin.helpers import InlineAdminFormSet, InlineAdminForm, AdminField from polymorphic.formsets import BasePolymorphicModelFormSet -class InlinePolymorphicAdminForm(InlineAdminForm): +class PolymorphicInlineAdminForm(InlineAdminForm): """ Expose the admin configuration for a form """ - pass -class InlinePolymorphicAdminFormSet(InlineAdminFormSet): +class PolymorphicInlineAdminFormSet(InlineAdminFormSet): """ Internally used class to expose the formset in the template. """ @@ -23,7 +23,7 @@ class InlinePolymorphicAdminFormSet(InlineAdminFormSet): 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) + super(PolymorphicInlineAdminFormSet, self).__init__(*args, **kwargs) def __iter__(self): """ @@ -35,7 +35,7 @@ class InlinePolymorphicAdminFormSet(InlineAdminFormSet): child_inline = self.opts.get_child_inline_instance(model) view_on_site_url = self.opts.get_view_on_site_url(original) - yield InlinePolymorphicAdminForm( + yield PolymorphicInlineAdminForm( formset=self.formset, form=form, fieldsets=self.get_child_fieldsets(child_inline), @@ -50,7 +50,7 @@ class InlinePolymorphicAdminFormSet(InlineAdminFormSet): 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( + yield PolymorphicInlineAdminForm( formset=self.formset, form=form, fieldsets=self.get_child_fieldsets(child_inline), @@ -81,7 +81,7 @@ class PolymorphicInlineSupportMixin(object): depending on the polymorphic type of the form instance. This is achieved by overwriting :func:`get_inline_formsets` to return - an :class:`InlinePolymorphicAdminFormSet` instead of a standard Django + an :class:`PolymorphicInlineAdminFormSet` instead of a standard Django :class:`~django.contrib.admin.helpers.InlineAdminFormSet` for the polymorphic formsets. """ @@ -98,7 +98,7 @@ class PolymorphicInlineSupportMixin(object): if isinstance(admin_formset.formset, BasePolymorphicModelFormSet): # This is a polymorphic formset, which belongs to our inline. # Downcast the admin wrapper that generates the form fields. - admin_formset.__class__ = InlinePolymorphicAdminFormSet + admin_formset.__class__ = PolymorphicInlineAdminFormSet admin_formset.request = request admin_formset.obj = obj return inline_admin_formsets diff --git a/polymorphic/admin/inlines.py b/polymorphic/admin/inlines.py index 7817cf2..1a72c23 100644 --- a/polymorphic/admin/inlines.py +++ b/polymorphic/admin/inlines.py @@ -13,7 +13,7 @@ from polymorphic.formsets import polymorphic_child_forms_factory, BasePolymorphi from polymorphic.formsets.utils import add_media -class PolymorphicParentInlineModelAdmin(InlineModelAdmin): +class PolymorphicInlineModelAdmin(InlineModelAdmin): """ A polymorphic inline, where each formset row can be a different form. @@ -30,11 +30,11 @@ class PolymorphicParentInlineModelAdmin(InlineModelAdmin): extra = 0 #: Inlines for all model sub types that can be displayed in this inline. - #: Each row is a :class:`PolymorphicChildInlineModelAdmin` + #: Each row is a :class:`PolymorphicInlineModelAdmin.Child` child_inlines = () def __init__(self, parent_model, admin_site): - super(PolymorphicParentInlineModelAdmin, self).__init__(parent_model, admin_site) + super(PolymorphicInlineModelAdmin, 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. @@ -47,7 +47,7 @@ class PolymorphicParentInlineModelAdmin(InlineModelAdmin): def get_child_inline_instances(self): """ - :rtype List[PolymorphicChildInlineModelAdmin] + :rtype List[PolymorphicInlineModelAdmin.Child] """ instances = [] for ChildInlineType in self.child_inlines: @@ -58,7 +58,7 @@ class PolymorphicParentInlineModelAdmin(InlineModelAdmin): """ Find the child inline for a given model. - :rtype: PolymorphicChildInlineModelAdmin + :rtype: PolymorphicInlineModelAdmin.Child """ try: return self._child_inlines_lookup[model] @@ -74,7 +74,7 @@ class PolymorphicParentInlineModelAdmin(InlineModelAdmin): :rtype: type """ # Construct the FormSet class - FormSet = super(PolymorphicParentInlineModelAdmin, self).get_formset(request, obj=obj, **kwargs) + FormSet = super(PolymorphicInlineModelAdmin, 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. @@ -115,7 +115,7 @@ class PolymorphicParentInlineModelAdmin(InlineModelAdmin): # 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 + base_media = super(PolymorphicInlineModelAdmin, self).media all_media = Media() add_media(all_media, base_media) @@ -127,87 +127,96 @@ class PolymorphicParentInlineModelAdmin(InlineModelAdmin): if child_media._css != base_media._css and child_media._js != base_media._js: add_media(all_media, child_media) + add_media(all_media, self.polymorphic_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): + class Child(InlineModelAdmin): """ - Return the formset child that the parent inline can use to represent us. + The child inline; which allows configuring the admin options + for the child appearance. - :rtype: PolymorphicFormSetChild + 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. """ - # 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)) + formset_child = PolymorphicFormSetChild + extra = 0 # TODO: currently unused for the children. - if self.exclude is None: - exclude = [] - else: - exclude = list(self.exclude) + def __init__(self, parent_inline): + self.parent_inline = parent_inline + super(PolymorphicInlineModelAdmin.Child, self).__init__(parent_inline.parent_model, parent_inline.admin_site) - exclude.extend(self.get_readonly_fields(request, obj)) + 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.") - 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) + def get_fields(self, request, obj=None): + if self.fields: + return self.fields - #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) + # 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)) - # 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) + 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) + + +class StackedPolymorphicInline(PolymorphicInlineModelAdmin): + """ + Stacked inline for django-polymorphic models. + Since tabular doesn't make much sense with changed fields, just offer this one. + """ + template = 'admin/polymorphic/edit_inline/stacked.html' diff --git a/polymorphic/formsets/__init__.py b/polymorphic/formsets/__init__.py index 4fb4114..079c506 100644 --- a/polymorphic/formsets/__init__.py +++ b/polymorphic/formsets/__init__.py @@ -34,18 +34,18 @@ from .models import ( ) from .generic import ( # Can import generic here, as polymorphic already depends on the 'contenttypes' app. - BasePolymorphicGenericInlineFormSet, - PolymorphicGenericFormSetChild, - polymorphic_generic_inlineformset_factory, + BaseGenericPolymorphicInlineFormSet, + GenericPolymorphicFormSetChild, + generic_polymorphic_inlineformset_factory, ) __all__ = ( 'BasePolymorphicModelFormSet', 'BasePolymorphicInlineFormSet', - 'BasePolymorphicGenericInlineFormSet', 'PolymorphicFormSetChild', - 'PolymorphicGenericFormSetChild', 'polymorphic_inlineformset_factory', - 'polymorphic_generic_inlineformset_factory', 'polymorphic_child_forms_factory', + 'BaseGenericPolymorphicInlineFormSet', + 'GenericPolymorphicFormSetChild', + 'generic_polymorphic_inlineformset_factory', ) diff --git a/polymorphic/formsets/generic.py b/polymorphic/formsets/generic.py index 0872906..2109d82 100644 --- a/polymorphic/formsets/generic.py +++ b/polymorphic/formsets/generic.py @@ -7,14 +7,14 @@ from django.forms.models import ModelForm from .models import BasePolymorphicModelFormSet, polymorphic_child_forms_factory, PolymorphicFormSetChild -class PolymorphicGenericFormSetChild(PolymorphicFormSetChild): +class GenericPolymorphicFormSetChild(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) + super(GenericPolymorphicFormSetChild, self).__init__(*args, **kwargs) def get_form(self, ct_field="content_type", fk_field="object_id", **kwargs): """ @@ -42,17 +42,17 @@ class PolymorphicGenericFormSetChild(PolymorphicFormSetChild): exclude.extend([ct_field.name, fk_field.name]) kwargs['exclude'] = exclude - return super(PolymorphicGenericFormSetChild, self).get_form(**kwargs) + return super(GenericPolymorphicFormSetChild, self).get_form(**kwargs) -class BasePolymorphicGenericInlineFormSet(BaseGenericInlineFormSet, BasePolymorphicModelFormSet): +class BaseGenericPolymorphicInlineFormSet(BaseGenericInlineFormSet, BasePolymorphicModelFormSet): """ Polymorphic formset variation for inline generic formsets """ -def polymorphic_generic_inlineformset_factory(model, formset_children, form=ModelForm, - formset=BasePolymorphicGenericInlineFormSet, +def generic_polymorphic_inlineformset_factory(model, formset_children, form=ModelForm, + formset=BaseGenericPolymorphicInlineFormSet, ct_field="content_type", fk_field="object_id", # Base form # TODO: should these fields be removed in favor of creating From 7330a4f0997166a51124583fbc1f77c0401657c1 Mon Sep 17 00:00:00 2001 From: Diederik van der Boor Date: Tue, 9 Aug 2016 01:20:40 +0200 Subject: [PATCH 4/8] Add preliminairy working JavaScript to render polymorphic inlines --- MANIFEST.in | 1 + polymorphic/admin/helpers.py | 32 ++ polymorphic/admin/inlines.py | 14 + polymorphic/formsets/models.py | 2 +- .../polymorphic/css/polymorphic_inlines.css | 27 ++ .../polymorphic/js/jquery.django-inlines.js | 440 ++++++++++++++++++ .../polymorphic/edit_inline/stacked.html | 37 ++ 7 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 polymorphic/static/polymorphic/css/polymorphic_inlines.css create mode 100644 polymorphic/static/polymorphic/js/jquery.django-inlines.js create mode 100644 polymorphic/templates/admin/polymorphic/edit_inline/stacked.html diff --git a/MANIFEST.in b/MANIFEST.in index 25899f9..315ce9c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,4 +2,5 @@ include README.rst include LICENSE include DOCS.rst include CHANGES.rst +recursive-include polymorphic/static *.js *.css recursive-include polymorphic/templates * diff --git a/polymorphic/admin/helpers.py b/polymorphic/admin/helpers.py index 32698c9..87c56d0 100644 --- a/polymorphic/admin/helpers.py +++ b/polymorphic/admin/helpers.py @@ -3,8 +3,13 @@ Rendering utils for admin forms; This makes sure that admin fieldsets/layout settings are exported to the template. """ +import json + import django from django.contrib.admin.helpers import InlineAdminFormSet, InlineAdminForm, AdminField +from django.utils.encoding import force_text +from django.utils.text import capfirst +from django.utils.translation import ugettext from polymorphic.formsets import BasePolymorphicModelFormSet @@ -14,6 +19,9 @@ class PolymorphicInlineAdminForm(InlineAdminForm): Expose the admin configuration for a form """ + def polymorphic_ctype_field(self): + return AdminField(self.form, 'polymorphic_ctype', False) + class PolymorphicInlineAdminFormSet(InlineAdminFormSet): """ @@ -71,6 +79,30 @@ class PolymorphicInlineAdminFormSet(InlineAdminFormSet): fields.update(child_inline.get_prepopulated_fields(self.request, self.obj)) return fields + # The polymorphic template follows the same method like all other inlines do in Django 1.10. + # This method is added for compatibility with older Django versions. + def inline_formset_data(self): + """ + A JavaScript data structure for the JavaScript code + """ + verbose_name = self.opts.verbose_name + return json.dumps({ + 'name': '#%s' % self.formset.prefix, + 'options': { + 'prefix': self.formset.prefix, + 'addText': ugettext('Add another %(verbose_name)s') % { + 'verbose_name': capfirst(verbose_name), + }, + 'childTypes': [ + { + 'type': model._meta.model_name, + 'name': force_text(model._meta.verbose_name) + } for model in self.formset.child_forms.keys() + ], + 'deleteText': ugettext('Remove'), + } + }) + class PolymorphicInlineSupportMixin(object): """ diff --git a/polymorphic/admin/inlines.py b/polymorphic/admin/inlines.py index 1a72c23..fc1657c 100644 --- a/polymorphic/admin/inlines.py +++ b/polymorphic/admin/inlines.py @@ -22,8 +22,22 @@ class PolymorphicInlineModelAdmin(InlineModelAdmin): * 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. """ + formset = BasePolymorphicInlineFormSet + #: The extra media to add for the polymorphic inlines effect. + #: This can be redefined for subclasses. + polymorphic_media = Media( + js=( + 'polymorphic/js/jquery.django-inlines.js', + ), + css={ + 'all': ( + 'polymorphic/css/polymorphic_inlines.css', + ) + } + ) + #: 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. diff --git a/polymorphic/formsets/models.py b/polymorphic/formsets/models.py index 4248ab4..45d1c82 100644 --- a/polymorphic/formsets/models.py +++ b/polymorphic/formsets/models.py @@ -26,7 +26,7 @@ class PolymorphicFormSetChild(object): # This is mostly needed for the generic inline formsets self._form_base = form self.fields = fields - self.exclude = exclude + self.exclude = exclude or () self.formfield_callback = formfield_callback self.widgets = widgets self.localized_fields = localized_fields diff --git a/polymorphic/static/polymorphic/css/polymorphic_inlines.css b/polymorphic/static/polymorphic/css/polymorphic_inlines.css new file mode 100644 index 0000000..daae436 --- /dev/null +++ b/polymorphic/static/polymorphic/css/polymorphic_inlines.css @@ -0,0 +1,27 @@ +.add-row-choice { + position: relative; +} + +.add-row-choice a:focus { + text-decoration: none; +} + +.add-row .inline-type-choice { + position: absolute; + top: 2.2em; + left: 0.5em; + border: 1px solid #ccc; + border-radius: 4px; + padding: 2px; + background-color: #fff; +} + +.add-row .inline-type-choice ul { + padding: 2px; + margin: 0; +} + +.add-row .inline-type-choice li { + list-style: none inside none; + padding: 4px 8px; +} diff --git a/polymorphic/static/polymorphic/js/jquery.django-inlines.js b/polymorphic/static/polymorphic/js/jquery.django-inlines.js new file mode 100644 index 0000000..6da2419 --- /dev/null +++ b/polymorphic/static/polymorphic/js/jquery.django-inlines.js @@ -0,0 +1,440 @@ +/** + * jQuery plugin for Django inlines + * + * (c) 2011-2016 Diederik van der Boor, Apache 2 Licensed. + */ + +(function($){ + + function DjangoInline(group, options) { + options = $.extend({}, $.fn.djangoInline.defaults, options); + + this.group = group; + this.$group = $(group); + this.options = options; + + options.prefix = options.prefix || this.$group.attr('id').replace(/-group$/, ''); + + if( options.formTemplate ) { + this.$form_template = $(options.formTemplate); + } else { + this.$form_template = this.$group.find(this.options.emptyFormSelector); // the extra item to construct new instances. + } + + // Create the add button if requested (null/undefined means auto select) + if(options.showAddButton !== false) { + var dominfo = this._getManagementForm(); + if (dominfo.max_forms == null || dominfo.max_forms.value === '' || (dominfo.max_forms.value - dominfo.total_forms.value) > 0) { + this.createAddButton(); + } + } + } + + DjangoInline.prototype = { + + /** + * Create the add button + */ + createAddButton: function() { + var $addButton; + var myself = this; + if (this.options.childTypes) { + // Polymorphic inlines! + // The add button opens a menu. + var menu = ''; + $addButton = $('"); + this.$group.append($addButton); + + $addButton.children('a').click($.proxy(this._onMenuToggle, this)); + $addButton.find('li a').click(function(event){ myself._onMenuItemClick(event); }); + } + else { + // Normal inlines + $addButton = $('"); + this.$group.append($addButton); + + $addButton.find('a').click(function(event) { event.preventDefault(); myself.addForm() }); + } + }, + + _onMenuToggle: function(event) { + event.preventDefault(); + event.stopPropagation(); + var $menu = $(event.target).next('.inline-type-choice'); + + if(! $menu.is(':visible')) { + function hideMenu() { + $menu.slideUp(); + $(document).unbind('click', hideMenu); + } + + $(document).click(hideMenu); + } + + $menu.slideToggle(); + }, + + _onMenuItemClick: function(event) { + event.preventDefault(); + var type = $(event.target).attr('data-type'); + var empty_form_selector = this.options.emptyFormSelector + "[data-inline-type=" + type + "]"; + this.addForm(empty_form_selector); + }, + + /** + * The main action, add a new row. + * Allow to select a different form template (for polymorphic inlines) + */ + addForm: function(emptyFormSelector) { + var $form_template; + + if(emptyFormSelector) { + $form_template = this.$group.find(emptyFormSelector); + if($form_template.length === 0) { + throw new Error("Form template '" + emptyFormSelector + "' not found") + } + } + else { + if(! this.$form_template || this.$form_template.length === 0) { + throw new Error("No empty form available. Define the 'form_template' setting or add an '.empty-form' element in the '" + this.options.prefix + "' formset group!"); + } + + $form_template = this.$form_template; + } + + // The Django admin/media/js/inlines.js API is not public, or easy to use. + // Recoded the inline model dynamics. + var management_form = this._getManagementForm(); + if(! management_form.total_forms) { + throw new Error("Missing '#" + this._getGroupFieldIdPrefix() + "-TOTAL_FORMS' field. Make sure the management form included!"); + } + + // When a inline is presented in a complex table, + // the newFormTarget can be very useful to direct the output. + var container; + if(this.options.newFormTarget == null) { + container = $form_template.parent(); + } + else if($.isFunction(this.options.newFormTarget)) { + container = this.options.newFormTarget.apply(this.group); + } + else { + container = this.$group.find(this.options.newFormTarget); + } + + if(container === null || container.length === 0) { + throw new Error("No container found via custom 'newFormTarget' function!"); + } + + // Clone the item. + var new_index = management_form.total_forms.value; + var item_id = this._getFormId(new_index); + var newhtml = _getOuterHtml($form_template).replace(/__prefix__/g, new_index); + var newitem = $(newhtml).removeClass("empty-form").attr("id", item_id); + + // Add it + container.append(newitem); + var formset_item = $("#" + item_id); + if( formset_item.length === 0 ) { + throw new Error("New FormSet item not found: #" + item_id); + } + + formset_item.data('djangoInlineIndex', new_index); + if(this.options.onAdd) { + this.options.onAdd.call(this.group, formset_item, new_index, this.options); + } + + // Update administration + management_form.total_forms.value++; + return formset_item; + }, + + getFormAt: function(index) { + return $('#' + this._getFormId(index)); + }, + + _getFormId: function(index) { + // The form container is expected by the numbered as #prefix-NR + return this.options.itemIdTemplate.replace('{prefix}', this.options.prefix).replace('{index}', index); + }, + + _getGroupFieldIdPrefix: function() { + // typically: #id_modelname + return this.options.autoId.replace('{prefix}', this.options.prefix); + }, + + /** + * Get the management form data. + */ + _getManagementForm: function() { + var group_id_prefix = this._getGroupFieldIdPrefix(); + return { + // management form item + total_forms: $("#" + group_id_prefix + "-TOTAL_FORMS")[0], + max_forms: $("#" + group_id_prefix + "-MAX_NUM_FORMS")[0], + group_id_prefix: group_id_prefix + } + }, + + _getItemData: function(child_node) { + var formset_item = $(child_node).closest(this.options.itemsSelector); + if( formset_item.length === 0 ) { + return null; + } + + // Split the ID, using the id_template pattern. + // note that ^...$ is important, as a '-' char can occur multiple times with generic inlines (inlinetype-id / app-model-ctfield-ctfkfield-id) + var id = formset_item.attr("id"); + var cap = (new RegExp('^' + this.options.itemIdTemplate.replace('{prefix}', '(.+?)').replace('{index}', '(\\d+)') + '$')).exec(id); + + return { + formset_item: formset_item, + prefix: cap[1], + index: parseInt(cap[2], 0) // or parseInt(formset_item.data('djangoInlineIndex')) + }; + }, + + /** + * Get the meta-data of a single form. + */ + _getItemForm: function(child_node) { + var dominfo = this._getItemData(child_node); + if( dominfo === null ) { + return null; + } + + var field_id_prefix = this._getGroupFieldIdPrefix() + "-" + dominfo.index; + return $.extend({}, dominfo, { + // Export settings data + field_id_prefix: field_id_prefix, + field_name_prefix: dominfo.prefix + '-' + dominfo.index, + + // Item fields + pk_field: $('#' + field_id_prefix + '-' + this.options.pkFieldName), + delete_checkbox: $("#" + field_id_prefix + "-DELETE") + }); + }, + + /** + * Remove a row + */ + removeForm: function(child_node) + { + // Get dom info + var management_form = this._getManagementForm(); + var itemform = this._getItemForm(child_node); + if( itemform === null ) { + throw new Error("No form found for the selector '" + child_node.selector + "'!"); + } + + var total_count = parseInt(management_form.total_forms.value, 0); + var has_pk_field = itemform.pk_field.length != 0; + + if(this.options.onBeforeRemove) { + this.options.onBeforeRemove.call(this.group, itemform.formset_item, this.options); + } + + // In case there is a delete checkbox, save it. + if( itemform.delete_checkbox.length ) + { + if(has_pk_field) + itemform.pk_field.insertAfter(management_form.total_forms); + itemform.delete_checkbox.attr('checked', true).insertAfter(management_form.total_forms).hide(); + } + else if( has_pk_field && itemform.pk_field[0].value ) + { + // Construct a delete checkbox on the fly. + itemform.pk_field.insertAfter(management_form.total_forms); + $('').insertAfter(itemform.total_forms); + } + else + { + // Newly added item, renumber in reverse order + for( var i = itemform.index + 1; i < total_count; i++ ) + { + this._renumberItem(this.getFormAt(i), i - 1); + } + + management_form.total_forms.value--; + } + + + // And remove item + itemform.formset_item.remove(); + + if(this.options.onRemove) { + this.options.onRemove.call(this.group, itemform.formset_item, this.options); + } + + return itemform.formset_item; + }, + + // Based on django/contrib/admin/media/js/inlines.js + _renumberItem: function($formset_item, new_index) + { + var id_regex = new RegExp("(" + this._getFormId('(\\d+|__prefix__)') + ")"); + var replacement = this._getFormId(new_index); + $formset_item.data('djangoInlineIndex', new_index); + + // Loop through the nodes. + // Getting them all at once turns out to be more efficient, then looping per level. + var nodes = $formset_item.add( $formset_item.find("*") ); + for( var i = 0; i < nodes.length; i++ ) + { + var node = nodes[i]; + var $node = $(node); + + var for_attr = $node.attr('for'); + if( for_attr && for_attr.match(id_regex) ) { + $node.attr("for", for_attr.replace(id_regex, replacement)); + } + + if( node.id && node.id.match(id_regex) ) { + node.id = node.id.replace(id_regex, replacement); + } + + if( node.name && node.name.match(id_regex) ) { + node.name = node.name.replace(id_regex, replacement); + } + } + }, + + // Extra query methods for external callers: + + getFormIndex: function(child_node) { + var dominfo = this._getItemData(child_node); + return dominfo ? dominfo.index : null; + }, + + getForms: function() { + // typically: .inline-related:not(.empty-form) + return this.$group.children(this.options.itemsSelector + ":not(" + this.options.emptyFormSelector + ")"); + }, + + getEmptyForm: function() { + // typically: #modelname-group > .empty-form + return this.$form_template; + }, + + getFieldIdPrefix: function(item_index) { + if(! $.isNumeric(item_index)) { + var dominfo = this._getItemData(item_index); + if(dominfo === null) { + throw new Error("Unexpected element in getFieldIdPrefix, needs to be item_index, or DOM child node."); + } + item_index = dominfo.index; + } + + // typically: #id_modelname-NN + return this._getGroupFieldIdPrefix() + "-" + item_index; + }, + + getFieldsAt: function(index) { + var $form = this.getFormAt(index); + return this.getFields($form); + }, + + getFields: function(child_node) { + // Return all fields in a simple lookup object, with the prefix stripped. + var dominfo = this._getItemData(child_node); + if(dominfo === null) { + return null; + } + + var fields = {}; + var $inputs = dominfo.formset_item.find(':input'); + var name_prefix = this.prefix + "-" + dominfo.index; + + for(var i = 0; i < $inputs.length; i++) { + var name = $inputs[i].name; + if(name.substring(0, name_prefix.length) == name_prefix) { + var suffix = name.substring(name_prefix.length + 1); // prefix- + fields[suffix] = $inputs[i]; + } + } + + return fields; + }, + + removeFormAt: function(index) { + return this.removeForm(this.getFormAt(index)); + } + }; + + + function _getOuterHtml($node) + { + if( $node.length ) + { + if( $node[0].outerHTML ) { + return $node[0].outerHTML; + } else { + return $("
").append($node.clone()).html(); + } + } + return null; + } + + + // jQuery plugin definition + // Separated from the main code, as demonstrated by Twitter bootstrap. + $.fn.djangoInline = function(option) { + var args = Array.prototype.splice.call(arguments, 1); + var call_method = (typeof option == 'string'); + var plugin_result = (call_method ? undefined : this); + + this.filter('.inline-group').each(function() { + var $this = $(this); + var data = $this.data('djangoInline'); + + if (! data) { + var options = typeof option == 'object' ? option : {}; + $this.data('djangoInline', (data = new DjangoInline(this, options))); + } + + if (typeof option == 'string') { + plugin_result = data[option].apply(data, args); + } + }); + + return plugin_result; + }; + + $.fn.djangoInline.defaults = { + pkFieldName: 'id', // can be `tablename_ptr` for inherited models. + autoId: 'id_{prefix}', // the auto id format used in Django. + prefix: null, // typically the model name in lower case. + newFormTarget: null, // Define where the row should be added; a CSS selector or function. + + itemIdTemplate: '{prefix}-{index}', // Format of the ID attribute. + itemsSelector: '.inline-related', // CSS class that each item has + emptyFormSelector: '.empty-form', // CSS class that + + formTemplate: null, // Complete HTML of the new form + childTypes: null, // Extra for django-polymorphic, allow a choice between empty-forms. + + showAddButton: true, + addText: "add another", // Text for the add link + deleteText: "remove", // Text for the delete link + addCssClass: "add-row" // CSS class applied to the add link + }; + + // Also expose inner object + $.fn.djangoInline.Constructor = DjangoInline; + + + // Auto enable inlines + $.fn.ready(function(){ + $('.js-jquery-django-inlines').each(function(){ + var $this = $(this); + var data = $this.data(); + var inlineOptions = data.inlineFormset; + $this.djangoInline(inlineOptions.options) + }); + }) +})(window.django ? window.django.jQuery : jQuery); diff --git a/polymorphic/templates/admin/polymorphic/edit_inline/stacked.html b/polymorphic/templates/admin/polymorphic/edit_inline/stacked.html new file mode 100644 index 0000000..592981e --- /dev/null +++ b/polymorphic/templates/admin/polymorphic/edit_inline/stacked.html @@ -0,0 +1,37 @@ +{% load i18n admin_urls static %} + +
+ +
+

{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}

+{{ inline_admin_formset.formset.management_form }} +{{ inline_admin_formset.formset.non_form_errors }} + +{% for inline_admin_form in inline_admin_formset %} + +{% endfor %} +
+
From a533dc4b23845639fbb2716708198628c0058f14 Mon Sep 17 00:00:00 2001 From: Diederik van der Boor Date: Tue, 9 Aug 2016 01:21:21 +0200 Subject: [PATCH 5/8] Update example to show polymorphic inlines --- example/example/settings.py | 1 + example/orders/__init__.py | 0 example/orders/admin.py | 43 +++++++++++ example/orders/migrations/0001_initial.py | 88 +++++++++++++++++++++++ example/orders/migrations/__init__.py | 0 example/orders/models.py | 78 ++++++++++++++++++++ 6 files changed, 210 insertions(+) create mode 100644 example/orders/__init__.py create mode 100644 example/orders/admin.py create mode 100644 example/orders/migrations/0001_initial.py create mode 100644 example/orders/migrations/__init__.py create mode 100644 example/orders/models.py diff --git a/example/example/settings.py b/example/example/settings.py index 9e33a05..8679620 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -73,6 +73,7 @@ INSTALLED_APPS = ( 'polymorphic', # needed if you want to use the polymorphic admin 'pexp', # this Django app is for testing and experimentation; not needed otherwise + 'orders', ) if django.VERSION >= (1, 7): diff --git a/example/orders/__init__.py b/example/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/orders/admin.py b/example/orders/admin.py new file mode 100644 index 0000000..755c3ad --- /dev/null +++ b/example/orders/admin.py @@ -0,0 +1,43 @@ +from django.contrib import admin + +from polymorphic.admin import PolymorphicInlineSupportMixin, StackedPolymorphicInline +from .models import Order, Payment, CreditCardPayment, BankPayment, SepaPayment + + +class CreditCardPaymentInline(StackedPolymorphicInline.Child): + model = CreditCardPayment + + +class BankPaymentInline(StackedPolymorphicInline.Child): + model = BankPayment + + +class SepaPaymentInline(StackedPolymorphicInline.Child): + model = SepaPayment + + +class PaymentInline(StackedPolymorphicInline): + """ + An inline for a polymorphic model. + The actual form appearance of each row is determined by + the child inline that corresponds with the actual model type. + """ + + model = Payment + child_inlines = ( + CreditCardPaymentInline, + BankPaymentInline, + SepaPaymentInline, + ) + + +@admin.register(Order) +class OrderAdmin(PolymorphicInlineSupportMixin, admin.ModelAdmin): + """ + Admin for orders. + The inline is polymorphic. + To make sure the inlines are properly handled, + the ``PolymorphicInlineSupportMixin`` is needed to + """ + inlines = (PaymentInline,) + diff --git a/example/orders/migrations/0001_initial.py b/example/orders/migrations/0001_initial.py new file mode 100644 index 0000000..f32d754 --- /dev/null +++ b/example/orders/migrations/0001_initial.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('title', models.CharField(max_length=200, verbose_name='Title')), + ], + options={ + 'ordering': ('title',), + 'verbose_name': 'Organisation', + 'verbose_name_plural': 'Organisations', + }, + ), + migrations.CreateModel( + name='Payment', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('currency', models.CharField(default=b'USD', max_length=3)), + ('amount', models.DecimalField(max_digits=10, decimal_places=2)), + ], + options={ + 'verbose_name': 'Payment', + 'verbose_name_plural': 'Payments', + }, + ), + migrations.CreateModel( + name='BankPayment', + fields=[ + ('payment_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='orders.Payment')), + ('bank_name', models.CharField(max_length=100)), + ('swift', models.CharField(max_length=20)), + ], + options={ + 'verbose_name': 'Bank Payment', + 'verbose_name_plural': 'Bank Payments', + }, + bases=('orders.payment',), + ), + migrations.CreateModel( + name='CreditCardPayment', + fields=[ + ('payment_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='orders.Payment')), + ('card_type', models.CharField(max_length=10)), + ('expiry_month', models.PositiveSmallIntegerField(choices=[(1, 'jan'), (2, 'feb'), (3, 'mar'), (4, 'apr'), (5, 'may'), (6, 'jun'), (7, 'jul'), (8, 'aug'), (9, 'sep'), (10, 'oct'), (11, 'nov'), (12, 'dec')])), + ('expiry_year', models.PositiveIntegerField()), + ], + options={ + 'verbose_name': 'Credit Card Payment', + 'verbose_name_plural': 'Credit Card Payments', + }, + bases=('orders.payment',), + ), + migrations.CreateModel( + name='SepaPayment', + fields=[ + ('payment_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='orders.Payment')), + ('iban', models.CharField(max_length=34)), + ('bic', models.CharField(max_length=11)), + ], + options={ + 'verbose_name': 'Bank Payment', + 'verbose_name_plural': 'Bank Payments', + }, + bases=('orders.payment',), + ), + migrations.AddField( + model_name='payment', + name='order', + field=models.ForeignKey(to='orders.Order'), + ), + migrations.AddField( + model_name='payment', + name='polymorphic_ctype', + field=models.ForeignKey(related_name='polymorphic_orders.payment_set+', editable=False, to='contenttypes.ContentType', null=True), + ), + ] diff --git a/example/orders/migrations/__init__.py b/example/orders/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/orders/models.py b/example/orders/models.py new file mode 100644 index 0000000..9691fae --- /dev/null +++ b/example/orders/models.py @@ -0,0 +1,78 @@ +from django.db import models +from django.utils.dates import MONTHS_3 +from django.utils.six import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ + +from polymorphic.models import PolymorphicModel + + +@python_2_unicode_compatible +class Order(models.Model): + """ + An example order that has polymorphic relations + """ + title = models.CharField(_("Title"), max_length=200) + + class Meta: + verbose_name = _("Organisation") + verbose_name_plural = _("Organisations") + ordering = ('title',) + + def __str__(self): + return self.title + + +@python_2_unicode_compatible +class Payment(PolymorphicModel): + """ + A generic payment model. + """ + order = models.ForeignKey(Order) + currency = models.CharField(default='USD', max_length=3) + amount = models.DecimalField(max_digits=10, decimal_places=2) + + class Meta: + verbose_name = _("Payment") + verbose_name_plural = _("Payments") + + def __str__(self): + return "{0} {1}".format(self.currency, self.amount) + + +class CreditCardPayment(Payment): + """ + Credit card + """ + MONTH_CHOICES = [(i, n) for i, n in sorted(MONTHS_3.items())] + + card_type = models.CharField(max_length=10) + expiry_month = models.PositiveSmallIntegerField(choices=MONTH_CHOICES) + expiry_year = models.PositiveIntegerField() + + class Meta: + verbose_name = _("Credit Card Payment") + verbose_name_plural = _("Credit Card Payments") + + +class BankPayment(Payment): + """ + Payment by bank + """ + bank_name = models.CharField(max_length=100) + swift = models.CharField(max_length=20) + + class Meta: + verbose_name = _("Bank Payment") + verbose_name_plural = _("Bank Payments") + + +class SepaPayment(Payment): + """ + Payment by SEPA (EU) + """ + iban = models.CharField(max_length=34) + bic = models.CharField(max_length=11) + + class Meta: + verbose_name = _("SEPA Payment") + verbose_name_plural = _("SEPA Payments") From 59e387883592ad0a07aadcc2eca329b3beaec515 Mon Sep 17 00:00:00 2001 From: Diederik van der Boor Date: Tue, 9 Aug 2016 01:21:37 +0200 Subject: [PATCH 6/8] Add detection for missing PolymorphicInlineSupportMixin --- polymorphic/admin/inlines.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/polymorphic/admin/inlines.py b/polymorphic/admin/inlines.py index fc1657c..779625f 100644 --- a/polymorphic/admin/inlines.py +++ b/polymorphic/admin/inlines.py @@ -7,10 +7,12 @@ from functools import partial from django.contrib.admin.options import InlineModelAdmin from django.contrib.admin.utils import flatten_fieldsets +from django.core.exceptions import ImproperlyConfigured from django.forms import Media from polymorphic.formsets import polymorphic_child_forms_factory, BasePolymorphicInlineFormSet, PolymorphicFormSetChild from polymorphic.formsets.utils import add_media +from .helpers import PolymorphicInlineSupportMixin class PolymorphicInlineModelAdmin(InlineModelAdmin): @@ -50,6 +52,16 @@ class PolymorphicInlineModelAdmin(InlineModelAdmin): def __init__(self, parent_model, admin_site): super(PolymorphicInlineModelAdmin, self).__init__(parent_model, admin_site) + # Extra check to avoid confusion + # While we could monkeypatch the admin here, better stay explicit. + parent_admin = admin_site._registry.get(parent_model, None) + if parent_admin is not None: # Can be None during check + if not isinstance(parent_admin, PolymorphicInlineSupportMixin): + raise ImproperlyConfigured( + "To use polymorphic inlines, add the `PolymorphicInlineSupportMixin` mixin " + "to the ModelAdmin that hosts the inline." + ) + # 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() From 249fc0088b7c7ff1a493288823e413a46fcb5451 Mon Sep 17 00:00:00 2001 From: Diederik van der Boor Date: Wed, 10 Aug 2016 11:42:31 +0200 Subject: [PATCH 7/8] Import standard Django inlines script, to be adjusted for polymorphic --- .../polymorphic/js/polymorphic_inlines.js | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 polymorphic/static/polymorphic/js/polymorphic_inlines.js diff --git a/polymorphic/static/polymorphic/js/polymorphic_inlines.js b/polymorphic/static/polymorphic/js/polymorphic_inlines.js new file mode 100644 index 0000000..4e9bb77 --- /dev/null +++ b/polymorphic/static/polymorphic/js/polymorphic_inlines.js @@ -0,0 +1,295 @@ +/*global DateTimeShortcuts, SelectFilter*/ +/** + * Django admin inlines + * + * Based on jQuery Formset 1.1 + * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) + * @requires jQuery 1.2.6 or later + * + * Copyright (c) 2009, Stanislaus Madueke + * All rights reserved. + * + * Spiced up with Code from Zain Memon's GSoC project 2009 + * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. + * + * Licensed under the New BSD License + * See: http://www.opensource.org/licenses/bsd-license.php + */ +(function($) { + 'use strict'; + $.fn.formset = function(opts) { + var options = $.extend({}, $.fn.formset.defaults, opts); + var $this = $(this); + var $parent = $this.parent(); + var updateElementIndex = function(el, prefix, ndx) { + var id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); + var replacement = prefix + "-" + ndx; + if ($(el).prop("for")) { + $(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); + } + if (el.id) { + el.id = el.id.replace(id_regex, replacement); + } + if (el.name) { + el.name = el.name.replace(id_regex, replacement); + } + }; + var totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off"); + var nextIndex = parseInt(totalForms.val(), 10); + var maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off"); + // only show the add button if we are allowed to add more items, + // note that max_num = None translates to a blank string. + var showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0; + $this.each(function(i) { + $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); + }); + if ($this.length && showAddButton) { + var addButton = options.addButton; + if (addButton === null) { + if ($this.prop("tagName") === "TR") { + // If forms are laid out as table rows, insert the + // "add" button in a new table row: + var numCols = this.eq(-1).children().length; + $parent.append('' + options.addText + ""); + addButton = $parent.find("tr:last a"); + } else { + // Otherwise, insert it immediately after the last form: + $this.filter(":last").after('"); + addButton = $this.filter(":last").next().find("a"); + } + } + addButton.click(function(e) { + e.preventDefault(); + var template = $("#" + options.prefix + "-empty"); + var row = template.clone(true); + row.removeClass(options.emptyCssClass) + .addClass(options.formCssClass) + .attr("id", options.prefix + "-" + nextIndex); + if (row.is("tr")) { + // If the forms are laid out in table rows, insert + // the remove button into the last table cell: + row.children(":last").append('"); + } else if (row.is("ul") || row.is("ol")) { + // If they're laid out as an ordered/unordered list, + // insert an
  • after the last list item: + row.append('
  • ' + options.deleteText + "
  • "); + } else { + // Otherwise, just insert the remove button as the + // last child element of the form's container: + row.children(":first").append('' + options.deleteText + ""); + } + row.find("*").each(function() { + updateElementIndex(this, options.prefix, totalForms.val()); + }); + // Insert the new form when it has been fully edited + row.insertBefore($(template)); + // Update number of total forms + $(totalForms).val(parseInt(totalForms.val(), 10) + 1); + nextIndex += 1; + // Hide add button in case we've hit the max, except we want to add infinitely + if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) { + addButton.parent().hide(); + } + // The delete button of each row triggers a bunch of other things + row.find("a." + options.deleteCssClass).click(function(e1) { + e1.preventDefault(); + // Remove the parent form containing this button: + row.remove(); + nextIndex -= 1; + // If a post-delete callback was provided, call it with the deleted form: + if (options.removed) { + options.removed(row); + } + $(document).trigger('formset:removed', [row, options.prefix]); + // Update the TOTAL_FORMS form count. + var forms = $("." + options.formCssClass); + $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); + // Show add button again once we drop below max + if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) { + addButton.parent().show(); + } + // Also, update names and ids for all remaining form controls + // so they remain in sequence: + var i, formCount; + var updateElementCallback = function() { + updateElementIndex(this, options.prefix, i); + }; + for (i = 0, formCount = forms.length; i < formCount; i++) { + updateElementIndex($(forms).get(i), options.prefix, i); + $(forms.get(i)).find("*").each(updateElementCallback); + } + }); + // If a post-add callback was supplied, call it with the added form: + if (options.added) { + options.added(row); + } + $(document).trigger('formset:added', [row, options.prefix]); + }); + } + return this; + }; + + /* Setup plugin defaults */ + $.fn.formset.defaults = { + prefix: "form", // The form prefix for your django formset + addText: "add another", // Text for the add link + deleteText: "remove", // Text for the delete link + addCssClass: "add-row", // CSS class applied to the add link + deleteCssClass: "delete-row", // CSS class applied to the delete link + emptyCssClass: "empty-row", // CSS class applied to the empty row + formCssClass: "dynamic-form", // CSS class applied to each form in a formset + added: null, // Function called each time a new form is added + removed: null, // Function called each time a form is deleted + addButton: null // Existing add button to use + }; + + + // Tabular inlines --------------------------------------------------------- + $.fn.tabularFormset = function(options) { + var $rows = $(this); + var alternatingRows = function(row) { + $($rows.selector).not(".add-row").removeClass("row1 row2") + .filter(":even").addClass("row1").end() + .filter(":odd").addClass("row2"); + }; + + var reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + var updateSelectFilter = function() { + // If any SelectFilter widgets are a part of the new form, + // instantiate a new SelectFilter instance for it. + if (typeof SelectFilter !== 'undefined') { + $('.selectfilter').each(function(index, value) { + var namearr = value.name.split('-'); + SelectFilter.init(value.id, namearr[namearr.length - 1], false); + }); + $('.selectfilterstacked').each(function(index, value) { + var namearr = value.name.split('-'); + SelectFilter.init(value.id, namearr[namearr.length - 1], true); + }); + } + }; + + var initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + var field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + removed: alternatingRows, + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + alternatingRows(row); + }, + addButton: options.addButton + }); + + return $rows; + }; + + // Stacked inlines --------------------------------------------------------- + $.fn.stackedFormset = function(options) { + var $rows = $(this); + var updateInlineLabel = function(row) { + $($rows.selector).find(".inline_label").each(function(i) { + var count = i + 1; + $(this).html($(this).html().replace(/(#\d+)/g, "#" + count)); + }); + }; + + var reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force, yuck. + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + var updateSelectFilter = function() { + // If any SelectFilter widgets were added, instantiate a new instance. + if (typeof SelectFilter !== "undefined") { + $(".selectfilter").each(function(index, value) { + var namearr = value.name.split('-'); + SelectFilter.init(value.id, namearr[namearr.length - 1], false); + }); + $(".selectfilterstacked").each(function(index, value) { + var namearr = value.name.split('-'); + SelectFilter.init(value.id, namearr[namearr.length - 1], true); + }); + } + }; + + var initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + var field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + dependencies.push('#' + row.find('.form-row .field-' + field_name).find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + removed: updateInlineLabel, + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + updateInlineLabel(row); + }, + addButton: options.addButton + }); + + return $rows; + }; + + $(document).ready(function() { + $(".js-inline-admin-formset").each(function() { + var data = $(this).data(), + inlineOptions = data.inlineFormset; + switch(data.inlineType) { + case "stacked": + $(inlineOptions.name + "-group .inline-related").stackedFormset(inlineOptions.options); + break; + case "tabular": + $(inlineOptions.name + "-group .tabular.inline-related tbody tr").tabularFormset(inlineOptions.options); + break; + } + }); + }); +})(django.jQuery); From 60db4f63b9895d0202307d6d067ac292c2dad0fa Mon Sep 17 00:00:00 2001 From: Diederik van der Boor Date: Wed, 10 Aug 2016 11:45:48 +0200 Subject: [PATCH 8/8] Update polymorphic inline script. Now based on standard Django script. This makes upgrading it to newer Django versions much easier. Changes for polymorphic are included in this commit --- polymorphic/admin/inlines.py | 2 +- .../polymorphic/css/polymorphic_inlines.css | 10 +- .../polymorphic/js/jquery.django-inlines.js | 440 ------------------ .../polymorphic/js/polymorphic_inlines.js | 95 ++-- .../polymorphic/edit_inline/stacked.html | 5 +- 5 files changed, 75 insertions(+), 477 deletions(-) delete mode 100644 polymorphic/static/polymorphic/js/jquery.django-inlines.js diff --git a/polymorphic/admin/inlines.py b/polymorphic/admin/inlines.py index 779625f..9fd6218 100644 --- a/polymorphic/admin/inlines.py +++ b/polymorphic/admin/inlines.py @@ -31,7 +31,7 @@ class PolymorphicInlineModelAdmin(InlineModelAdmin): #: This can be redefined for subclasses. polymorphic_media = Media( js=( - 'polymorphic/js/jquery.django-inlines.js', + 'polymorphic/js/polymorphic_inlines.js', ), css={ 'all': ( diff --git a/polymorphic/static/polymorphic/css/polymorphic_inlines.css b/polymorphic/static/polymorphic/css/polymorphic_inlines.css index daae436..eb6d86c 100644 --- a/polymorphic/static/polymorphic/css/polymorphic_inlines.css +++ b/polymorphic/static/polymorphic/css/polymorphic_inlines.css @@ -1,12 +1,12 @@ -.add-row-choice { +.polymorphic-add-choice { position: relative; } -.add-row-choice a:focus { +.polymorphic-add-choice a:focus { text-decoration: none; } -.add-row .inline-type-choice { +.polymorphic-type-menu { position: absolute; top: 2.2em; left: 0.5em; @@ -16,12 +16,12 @@ background-color: #fff; } -.add-row .inline-type-choice ul { +.polymorphic-type-menu ul { padding: 2px; margin: 0; } -.add-row .inline-type-choice li { +.polymorphic-type-menu li { list-style: none inside none; padding: 4px 8px; } diff --git a/polymorphic/static/polymorphic/js/jquery.django-inlines.js b/polymorphic/static/polymorphic/js/jquery.django-inlines.js deleted file mode 100644 index 6da2419..0000000 --- a/polymorphic/static/polymorphic/js/jquery.django-inlines.js +++ /dev/null @@ -1,440 +0,0 @@ -/** - * jQuery plugin for Django inlines - * - * (c) 2011-2016 Diederik van der Boor, Apache 2 Licensed. - */ - -(function($){ - - function DjangoInline(group, options) { - options = $.extend({}, $.fn.djangoInline.defaults, options); - - this.group = group; - this.$group = $(group); - this.options = options; - - options.prefix = options.prefix || this.$group.attr('id').replace(/-group$/, ''); - - if( options.formTemplate ) { - this.$form_template = $(options.formTemplate); - } else { - this.$form_template = this.$group.find(this.options.emptyFormSelector); // the extra item to construct new instances. - } - - // Create the add button if requested (null/undefined means auto select) - if(options.showAddButton !== false) { - var dominfo = this._getManagementForm(); - if (dominfo.max_forms == null || dominfo.max_forms.value === '' || (dominfo.max_forms.value - dominfo.total_forms.value) > 0) { - this.createAddButton(); - } - } - } - - DjangoInline.prototype = { - - /** - * Create the add button - */ - createAddButton: function() { - var $addButton; - var myself = this; - if (this.options.childTypes) { - // Polymorphic inlines! - // The add button opens a menu. - var menu = ''; - $addButton = $('"); - this.$group.append($addButton); - - $addButton.children('a').click($.proxy(this._onMenuToggle, this)); - $addButton.find('li a').click(function(event){ myself._onMenuItemClick(event); }); - } - else { - // Normal inlines - $addButton = $('"); - this.$group.append($addButton); - - $addButton.find('a').click(function(event) { event.preventDefault(); myself.addForm() }); - } - }, - - _onMenuToggle: function(event) { - event.preventDefault(); - event.stopPropagation(); - var $menu = $(event.target).next('.inline-type-choice'); - - if(! $menu.is(':visible')) { - function hideMenu() { - $menu.slideUp(); - $(document).unbind('click', hideMenu); - } - - $(document).click(hideMenu); - } - - $menu.slideToggle(); - }, - - _onMenuItemClick: function(event) { - event.preventDefault(); - var type = $(event.target).attr('data-type'); - var empty_form_selector = this.options.emptyFormSelector + "[data-inline-type=" + type + "]"; - this.addForm(empty_form_selector); - }, - - /** - * The main action, add a new row. - * Allow to select a different form template (for polymorphic inlines) - */ - addForm: function(emptyFormSelector) { - var $form_template; - - if(emptyFormSelector) { - $form_template = this.$group.find(emptyFormSelector); - if($form_template.length === 0) { - throw new Error("Form template '" + emptyFormSelector + "' not found") - } - } - else { - if(! this.$form_template || this.$form_template.length === 0) { - throw new Error("No empty form available. Define the 'form_template' setting or add an '.empty-form' element in the '" + this.options.prefix + "' formset group!"); - } - - $form_template = this.$form_template; - } - - // The Django admin/media/js/inlines.js API is not public, or easy to use. - // Recoded the inline model dynamics. - var management_form = this._getManagementForm(); - if(! management_form.total_forms) { - throw new Error("Missing '#" + this._getGroupFieldIdPrefix() + "-TOTAL_FORMS' field. Make sure the management form included!"); - } - - // When a inline is presented in a complex table, - // the newFormTarget can be very useful to direct the output. - var container; - if(this.options.newFormTarget == null) { - container = $form_template.parent(); - } - else if($.isFunction(this.options.newFormTarget)) { - container = this.options.newFormTarget.apply(this.group); - } - else { - container = this.$group.find(this.options.newFormTarget); - } - - if(container === null || container.length === 0) { - throw new Error("No container found via custom 'newFormTarget' function!"); - } - - // Clone the item. - var new_index = management_form.total_forms.value; - var item_id = this._getFormId(new_index); - var newhtml = _getOuterHtml($form_template).replace(/__prefix__/g, new_index); - var newitem = $(newhtml).removeClass("empty-form").attr("id", item_id); - - // Add it - container.append(newitem); - var formset_item = $("#" + item_id); - if( formset_item.length === 0 ) { - throw new Error("New FormSet item not found: #" + item_id); - } - - formset_item.data('djangoInlineIndex', new_index); - if(this.options.onAdd) { - this.options.onAdd.call(this.group, formset_item, new_index, this.options); - } - - // Update administration - management_form.total_forms.value++; - return formset_item; - }, - - getFormAt: function(index) { - return $('#' + this._getFormId(index)); - }, - - _getFormId: function(index) { - // The form container is expected by the numbered as #prefix-NR - return this.options.itemIdTemplate.replace('{prefix}', this.options.prefix).replace('{index}', index); - }, - - _getGroupFieldIdPrefix: function() { - // typically: #id_modelname - return this.options.autoId.replace('{prefix}', this.options.prefix); - }, - - /** - * Get the management form data. - */ - _getManagementForm: function() { - var group_id_prefix = this._getGroupFieldIdPrefix(); - return { - // management form item - total_forms: $("#" + group_id_prefix + "-TOTAL_FORMS")[0], - max_forms: $("#" + group_id_prefix + "-MAX_NUM_FORMS")[0], - group_id_prefix: group_id_prefix - } - }, - - _getItemData: function(child_node) { - var formset_item = $(child_node).closest(this.options.itemsSelector); - if( formset_item.length === 0 ) { - return null; - } - - // Split the ID, using the id_template pattern. - // note that ^...$ is important, as a '-' char can occur multiple times with generic inlines (inlinetype-id / app-model-ctfield-ctfkfield-id) - var id = formset_item.attr("id"); - var cap = (new RegExp('^' + this.options.itemIdTemplate.replace('{prefix}', '(.+?)').replace('{index}', '(\\d+)') + '$')).exec(id); - - return { - formset_item: formset_item, - prefix: cap[1], - index: parseInt(cap[2], 0) // or parseInt(formset_item.data('djangoInlineIndex')) - }; - }, - - /** - * Get the meta-data of a single form. - */ - _getItemForm: function(child_node) { - var dominfo = this._getItemData(child_node); - if( dominfo === null ) { - return null; - } - - var field_id_prefix = this._getGroupFieldIdPrefix() + "-" + dominfo.index; - return $.extend({}, dominfo, { - // Export settings data - field_id_prefix: field_id_prefix, - field_name_prefix: dominfo.prefix + '-' + dominfo.index, - - // Item fields - pk_field: $('#' + field_id_prefix + '-' + this.options.pkFieldName), - delete_checkbox: $("#" + field_id_prefix + "-DELETE") - }); - }, - - /** - * Remove a row - */ - removeForm: function(child_node) - { - // Get dom info - var management_form = this._getManagementForm(); - var itemform = this._getItemForm(child_node); - if( itemform === null ) { - throw new Error("No form found for the selector '" + child_node.selector + "'!"); - } - - var total_count = parseInt(management_form.total_forms.value, 0); - var has_pk_field = itemform.pk_field.length != 0; - - if(this.options.onBeforeRemove) { - this.options.onBeforeRemove.call(this.group, itemform.formset_item, this.options); - } - - // In case there is a delete checkbox, save it. - if( itemform.delete_checkbox.length ) - { - if(has_pk_field) - itemform.pk_field.insertAfter(management_form.total_forms); - itemform.delete_checkbox.attr('checked', true).insertAfter(management_form.total_forms).hide(); - } - else if( has_pk_field && itemform.pk_field[0].value ) - { - // Construct a delete checkbox on the fly. - itemform.pk_field.insertAfter(management_form.total_forms); - $('').insertAfter(itemform.total_forms); - } - else - { - // Newly added item, renumber in reverse order - for( var i = itemform.index + 1; i < total_count; i++ ) - { - this._renumberItem(this.getFormAt(i), i - 1); - } - - management_form.total_forms.value--; - } - - - // And remove item - itemform.formset_item.remove(); - - if(this.options.onRemove) { - this.options.onRemove.call(this.group, itemform.formset_item, this.options); - } - - return itemform.formset_item; - }, - - // Based on django/contrib/admin/media/js/inlines.js - _renumberItem: function($formset_item, new_index) - { - var id_regex = new RegExp("(" + this._getFormId('(\\d+|__prefix__)') + ")"); - var replacement = this._getFormId(new_index); - $formset_item.data('djangoInlineIndex', new_index); - - // Loop through the nodes. - // Getting them all at once turns out to be more efficient, then looping per level. - var nodes = $formset_item.add( $formset_item.find("*") ); - for( var i = 0; i < nodes.length; i++ ) - { - var node = nodes[i]; - var $node = $(node); - - var for_attr = $node.attr('for'); - if( for_attr && for_attr.match(id_regex) ) { - $node.attr("for", for_attr.replace(id_regex, replacement)); - } - - if( node.id && node.id.match(id_regex) ) { - node.id = node.id.replace(id_regex, replacement); - } - - if( node.name && node.name.match(id_regex) ) { - node.name = node.name.replace(id_regex, replacement); - } - } - }, - - // Extra query methods for external callers: - - getFormIndex: function(child_node) { - var dominfo = this._getItemData(child_node); - return dominfo ? dominfo.index : null; - }, - - getForms: function() { - // typically: .inline-related:not(.empty-form) - return this.$group.children(this.options.itemsSelector + ":not(" + this.options.emptyFormSelector + ")"); - }, - - getEmptyForm: function() { - // typically: #modelname-group > .empty-form - return this.$form_template; - }, - - getFieldIdPrefix: function(item_index) { - if(! $.isNumeric(item_index)) { - var dominfo = this._getItemData(item_index); - if(dominfo === null) { - throw new Error("Unexpected element in getFieldIdPrefix, needs to be item_index, or DOM child node."); - } - item_index = dominfo.index; - } - - // typically: #id_modelname-NN - return this._getGroupFieldIdPrefix() + "-" + item_index; - }, - - getFieldsAt: function(index) { - var $form = this.getFormAt(index); - return this.getFields($form); - }, - - getFields: function(child_node) { - // Return all fields in a simple lookup object, with the prefix stripped. - var dominfo = this._getItemData(child_node); - if(dominfo === null) { - return null; - } - - var fields = {}; - var $inputs = dominfo.formset_item.find(':input'); - var name_prefix = this.prefix + "-" + dominfo.index; - - for(var i = 0; i < $inputs.length; i++) { - var name = $inputs[i].name; - if(name.substring(0, name_prefix.length) == name_prefix) { - var suffix = name.substring(name_prefix.length + 1); // prefix- - fields[suffix] = $inputs[i]; - } - } - - return fields; - }, - - removeFormAt: function(index) { - return this.removeForm(this.getFormAt(index)); - } - }; - - - function _getOuterHtml($node) - { - if( $node.length ) - { - if( $node[0].outerHTML ) { - return $node[0].outerHTML; - } else { - return $("
    ").append($node.clone()).html(); - } - } - return null; - } - - - // jQuery plugin definition - // Separated from the main code, as demonstrated by Twitter bootstrap. - $.fn.djangoInline = function(option) { - var args = Array.prototype.splice.call(arguments, 1); - var call_method = (typeof option == 'string'); - var plugin_result = (call_method ? undefined : this); - - this.filter('.inline-group').each(function() { - var $this = $(this); - var data = $this.data('djangoInline'); - - if (! data) { - var options = typeof option == 'object' ? option : {}; - $this.data('djangoInline', (data = new DjangoInline(this, options))); - } - - if (typeof option == 'string') { - plugin_result = data[option].apply(data, args); - } - }); - - return plugin_result; - }; - - $.fn.djangoInline.defaults = { - pkFieldName: 'id', // can be `tablename_ptr` for inherited models. - autoId: 'id_{prefix}', // the auto id format used in Django. - prefix: null, // typically the model name in lower case. - newFormTarget: null, // Define where the row should be added; a CSS selector or function. - - itemIdTemplate: '{prefix}-{index}', // Format of the ID attribute. - itemsSelector: '.inline-related', // CSS class that each item has - emptyFormSelector: '.empty-form', // CSS class that - - formTemplate: null, // Complete HTML of the new form - childTypes: null, // Extra for django-polymorphic, allow a choice between empty-forms. - - showAddButton: true, - addText: "add another", // Text for the add link - deleteText: "remove", // Text for the delete link - addCssClass: "add-row" // CSS class applied to the add link - }; - - // Also expose inner object - $.fn.djangoInline.Constructor = DjangoInline; - - - // Auto enable inlines - $.fn.ready(function(){ - $('.js-jquery-django-inlines').each(function(){ - var $this = $(this); - var data = $this.data(); - var inlineOptions = data.inlineFormset; - $this.djangoInline(inlineOptions.options) - }); - }) -})(window.django ? window.django.jQuery : jQuery); diff --git a/polymorphic/static/polymorphic/js/polymorphic_inlines.js b/polymorphic/static/polymorphic/js/polymorphic_inlines.js index 4e9bb77..b321993 100644 --- a/polymorphic/static/polymorphic/js/polymorphic_inlines.js +++ b/polymorphic/static/polymorphic/js/polymorphic_inlines.js @@ -1,4 +1,8 @@ /*global DateTimeShortcuts, SelectFilter*/ + +// This is a slightly adapted version of Django's inlines.js +// Forked for polymorphic by Diederik van der Boor + /** * Django admin inlines * @@ -17,8 +21,8 @@ */ (function($) { 'use strict'; - $.fn.formset = function(opts) { - var options = $.extend({}, $.fn.formset.defaults, opts); + $.fn.polymorphicFormset = function(opts) { + var options = $.extend({}, $.fn.polymorphicFormset.defaults, opts); var $this = $(this); var $parent = $this.parent(); var updateElementIndex = function(el, prefix, ndx) { @@ -44,23 +48,55 @@ $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); }); if ($this.length && showAddButton) { - var addButton = options.addButton; - if (addButton === null) { - if ($this.prop("tagName") === "TR") { - // If forms are laid out as table rows, insert the - // "add" button in a new table row: - var numCols = this.eq(-1).children().length; - $parent.append('' + options.addText + ""); - addButton = $parent.find("tr:last a"); - } else { - // Otherwise, insert it immediately after the last form: - $this.filter(":last").after('"); - addButton = $this.filter(":last").next().find("a"); - } + var addContainer; + var menuButton; + var addButtons; + + // For Polymorphic inlines, the add button opens a menu. + var menu = ''; + + if ($this.prop("tagName") === "TR") { + // If forms are laid out as table rows, insert the + // "add" button in a new table row: + var numCols = this.eq(-1).children().length; + $parent.append('' + options.addText + "" + menu + ""); + addContainer = $parent.find("tr:last > td"); + menuButton = addContainer.children('a'); + addButtons = addContainer.find("li a"); + } else { + // Otherwise, insert it immediately after the last form: + $this.filter(":last").after('"); + addContainer = $this.filter(":last").next(); + menuButton = addContainer.children('a'); + addButtons = addContainer.find("li a"); + } + + menuButton.click(function(event) { + event.preventDefault(); + event.stopPropagation(); // for menu hide + var $menu = $(event.target).next('.polymorphic-type-menu'); + + if(! $menu.is(':visible')) { + var hideMenu = function() { + $menu.slideUp(50); + $(document).unbind('click', hideMenu); + }; + + $(document).click(hideMenu); + } + + $menu.slideToggle(50); + }); + + addButtons.click(function(event) { + event.preventDefault(); + var polymorphicType = $(event.target).attr('data-type'); // Select polymorphic type. + var template = $("#" + polymorphicType + "-empty"); var row = template.clone(true); row.removeClass(options.emptyCssClass) .addClass(options.formCssClass) @@ -88,7 +124,7 @@ nextIndex += 1; // Hide add button in case we've hit the max, except we want to add infinitely if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) { - addButton.parent().hide(); + addButtons.parent().hide(); } // The delete button of each row triggers a bunch of other things row.find("a." + options.deleteCssClass).click(function(e1) { @@ -106,7 +142,7 @@ $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); // Show add button again once we drop below max if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) { - addButton.parent().show(); + addButtons.parent().show(); } // Also, update names and ids for all remaining form controls // so they remain in sequence: @@ -130,9 +166,10 @@ }; /* Setup plugin defaults */ - $.fn.formset.defaults = { + $.fn.polymorphicFormset.defaults = { prefix: "form", // The form prefix for your django formset addText: "add another", // Text for the add link + childTypes: null, // defined by the client. deleteText: "remove", // Text for the delete link addCssClass: "add-row", // CSS class applied to the add link deleteCssClass: "delete-row", // CSS class applied to the delete link @@ -145,7 +182,7 @@ // Tabular inlines --------------------------------------------------------- - $.fn.tabularFormset = function(options) { + $.fn.tabularPolymorphicFormset = function(options) { var $rows = $(this); var alternatingRows = function(row) { $($rows.selector).not(".add-row").removeClass("row1 row2") @@ -191,9 +228,10 @@ }); }; - $rows.formset({ + $rows.polymorphicFormset({ prefix: options.prefix, addText: options.addText, + childTypes: options.childTypes, formCssClass: "dynamic-" + options.prefix, deleteCssClass: "inline-deletelink", deleteText: options.deleteText, @@ -212,7 +250,7 @@ }; // Stacked inlines --------------------------------------------------------- - $.fn.stackedFormset = function(options) { + $.fn.stackedPolymorphicFormset = function(options) { var $rows = $(this); var updateInlineLabel = function(row) { $($rows.selector).find(".inline_label").each(function(i) { @@ -258,9 +296,10 @@ }); }; - $rows.formset({ + $rows.polymorphicFormset({ prefix: options.prefix, addText: options.addText, + childTypes: options.childTypes, formCssClass: "dynamic-" + options.prefix, deleteCssClass: "inline-deletelink", deleteText: options.deleteText, @@ -279,15 +318,15 @@ }; $(document).ready(function() { - $(".js-inline-admin-formset").each(function() { + $(".js-inline-polymorphic-admin-formset").each(function() { var data = $(this).data(), inlineOptions = data.inlineFormset; switch(data.inlineType) { case "stacked": - $(inlineOptions.name + "-group .inline-related").stackedFormset(inlineOptions.options); + $(inlineOptions.name + "-group .inline-related").stackedPolymorphicFormset(inlineOptions.options); break; case "tabular": - $(inlineOptions.name + "-group .tabular.inline-related tbody tr").tabularFormset(inlineOptions.options); + $(inlineOptions.name + "-group .tabular.inline-related tbody tr").tabularPolymorphicFormset(inlineOptions.options); break; } }); diff --git a/polymorphic/templates/admin/polymorphic/edit_inline/stacked.html b/polymorphic/templates/admin/polymorphic/edit_inline/stacked.html index 592981e..8dc4d3b 100644 --- a/polymorphic/templates/admin/polymorphic/edit_inline/stacked.html +++ b/polymorphic/templates/admin/polymorphic/edit_inline/stacked.html @@ -1,6 +1,6 @@ {% load i18n admin_urls static %} -
    @@ -12,8 +12,7 @@ {% for inline_admin_form in inline_admin_formset %}