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/docs/admin.rst b/docs/admin.rst index 60885bb..b81a9a2 100644 --- a/docs/admin.rst +++ b/docs/admin.rst @@ -59,10 +59,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 479752d..773c885 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -55,6 +55,7 @@ Getting started quickstart admin + formsets performance Advanced topics 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") diff --git a/polymorphic/admin/__init__.py b/polymorphic/admin/__init__.py index fe0c31c..e067b62 100644 --- a/polymorphic/admin/__init__.py +++ b/polymorphic/admin/__init__.py @@ -4,12 +4,43 @@ 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 ( + PolymorphicInlineModelAdmin, # base class + StackedPolymorphicInline, # stacked inline +) + +# Helpers for the inlines +from .helpers import ( + 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 ( + GenericPolymorphicInlineModelAdmin, # base class + GenericStackedPolymorphicInline, # stacked inline +) + +__all__ = ( + 'PolymorphicParentModelAdmin', + 'PolymorphicChildModelAdmin', + 'PolymorphicModelChoiceForm', + 'PolymorphicChildModelFilter', + 'PolymorphicInlineAdminForm', + 'PolymorphicInlineAdminFormSet', + 'PolymorphicInlineSupportMixin', + 'PolymorphicInlineModelAdmin', + 'GenericPolymorphicInlineModelAdmin', + 'GenericStackedPolymorphicInline', ) diff --git a/polymorphic/admin/childadmin.py b/polymorphic/admin/childadmin.py index 854d0e9..c6ce41c 100644 --- a/polymorphic/admin/childadmin.py +++ b/polymorphic/admin/childadmin.py @@ -6,6 +6,8 @@ from django.core.urlresolvers import resolve from django.utils import six from django.utils.translation import ugettext_lazy as _ +from .helpers import PolymorphicInlineSupportMixin + class ParentAdminNotRegistered(RuntimeError): "The admin site for the model is not registered." 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/generic.py b/polymorphic/admin/generic.py new file mode 100644 index 0000000..747be67 --- /dev/null +++ b/polymorphic/admin/generic.py @@ -0,0 +1,61 @@ +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, BaseGenericPolymorphicInlineFormSet, GenericPolymorphicFormSetChild +from .inlines import PolymorphicInlineModelAdmin + + +class GenericPolymorphicInlineModelAdmin(PolymorphicInlineModelAdmin, GenericInlineModelAdmin): + """ + Base class for variation of inlines based on generic foreign keys. + """ + formset = BaseGenericPolymorphicInlineFormSet + + 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 Child(PolymorphicInlineModelAdmin.Child): + """ + Variation for generic inlines. + """ + # Make sure that the GFK fields are excluded from the child forms + formset_child = GenericPolymorphicFormSetChild + 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(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 new file mode 100644 index 0000000..87c56d0 --- /dev/null +++ b/polymorphic/admin/helpers.py @@ -0,0 +1,136 @@ +""" +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 + + +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): + """ + 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(PolymorphicInlineAdminFormSet, 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 PolymorphicInlineAdminForm( + 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 PolymorphicInlineAdminForm( + 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 + + # 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): + """ + 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:`PolymorphicInlineAdminFormSet` 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): + """ + 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): + # This is a polymorphic formset, which belongs to our inline. + # Downcast the admin wrapper that generates the form fields. + 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 new file mode 100644 index 0000000..9fd6218 --- /dev/null +++ b/polymorphic/admin/inlines.py @@ -0,0 +1,248 @@ +""" +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.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): + """ + 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. + """ + + formset = BasePolymorphicInlineFormSet + + #: The extra media to add for the polymorphic inlines effect. + #: This can be redefined for subclasses. + polymorphic_media = Media( + js=( + 'polymorphic/js/polymorphic_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. + extra = 0 + + #: Inlines for all model sub types that can be displayed in this inline. + #: Each row is a :class:`PolymorphicInlineModelAdmin.Child` + child_inlines = () + + 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() + + # 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[PolymorphicInlineModelAdmin.Child] + """ + 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: PolymorphicInlineModelAdmin.Child + """ + 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(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. + # 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) + ) + 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(PolymorphicInlineModelAdmin, 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) + + add_media(all_media, self.polymorphic_media) + + return all_media + + class Child(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(PolymorphicInlineModelAdmin.Child, 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) + + +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 new file mode 100644 index 0000000..079c506 --- /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. + BaseGenericPolymorphicInlineFormSet, + GenericPolymorphicFormSetChild, + generic_polymorphic_inlineformset_factory, +) + +__all__ = ( + 'BasePolymorphicModelFormSet', + 'BasePolymorphicInlineFormSet', + 'PolymorphicFormSetChild', + 'polymorphic_inlineformset_factory', + 'polymorphic_child_forms_factory', + 'BaseGenericPolymorphicInlineFormSet', + 'GenericPolymorphicFormSetChild', + 'generic_polymorphic_inlineformset_factory', +) diff --git a/polymorphic/formsets/generic.py b/polymorphic/formsets/generic.py new file mode 100644 index 0000000..2109d82 --- /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 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(GenericPolymorphicFormSetChild, 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(GenericPolymorphicFormSetChild, self).get_form(**kwargs) + + +class BaseGenericPolymorphicInlineFormSet(BaseGenericInlineFormSet, BasePolymorphicModelFormSet): + """ + Polymorphic formset variation for inline generic formsets + """ + + +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 + # 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..45d1c82 --- /dev/null +++ b/polymorphic/formsets/models.py @@ -0,0 +1,313 @@ +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 or () + 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. + 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() + + 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): + """ + 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] + + 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) diff --git a/polymorphic/static/polymorphic/css/polymorphic_inlines.css b/polymorphic/static/polymorphic/css/polymorphic_inlines.css new file mode 100644 index 0000000..eb6d86c --- /dev/null +++ b/polymorphic/static/polymorphic/css/polymorphic_inlines.css @@ -0,0 +1,27 @@ +.polymorphic-add-choice { + position: relative; +} + +.polymorphic-add-choice a:focus { + text-decoration: none; +} + +.polymorphic-type-menu { + position: absolute; + top: 2.2em; + left: 0.5em; + border: 1px solid #ccc; + border-radius: 4px; + padding: 2px; + background-color: #fff; +} + +.polymorphic-type-menu ul { + padding: 2px; + margin: 0; +} + +.polymorphic-type-menu li { + list-style: none inside none; + padding: 4px 8px; +} diff --git a/polymorphic/static/polymorphic/js/polymorphic_inlines.js b/polymorphic/static/polymorphic/js/polymorphic_inlines.js new file mode 100644 index 0000000..b321993 --- /dev/null +++ b/polymorphic/static/polymorphic/js/polymorphic_inlines.js @@ -0,0 +1,334 @@ +/*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 + * + * 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.polymorphicFormset = function(opts) { + var options = $.extend({}, $.fn.polymorphicFormset.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 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('