diff --git a/AUTHORS.rst b/AUTHORS.rst index 0aa569a..840ecd1 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -12,3 +12,4 @@ Contributors * Charles Leifer (python 2.4 compatibility) * Germán M. Bravo * Martin Brochhaus +* Diederik van der Boor (polymorphic admin interface) diff --git a/CHANGES.rst b/CHANGES.rst index e98152b..4053fb2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,12 @@ Changelog ++++++++++ +Juli 5, 2012, Polymorphic admin interface +========================================= + +Added a polymorphic admin interface. The admin interface is able to add polymorphic models, +and the admin edit screen also displays the custom fields of the polymorphic model. + 2011-12-20 Renaming, refactoring, new maintainer ================================================ diff --git a/DOCS.rst b/DOCS.rst index ded88dd..2454730 100644 --- a/DOCS.rst +++ b/DOCS.rst @@ -153,6 +153,107 @@ In the examples below, these models are being used:: field3 = models.CharField(max_length=10) +Using polymorphic models in the admin interface +----------------------------------------------- + +Naturally, it's possible to register individual polymorphic models in the Django admin interface. +However, to use these models in a single cohesive interface, some extra base classes are available. + +The polymorphic admin interface works in a simple way: + +* The add screen gains an additional step where the desired child model is selected. +* The edit screen displays the admin interface of the child model. +* The list screen still displays all objects of the base class. + +The polymorphic admin is implemented via a parent admin that forwards the *edit* and *delete* views +to the ``ModelAdmin`` of the derived child model. Hence, both the parent model and child model +need to have a ``ModelAdmin`` class. Only the ``ModelAdmin`` class of the parent/base model +has to be registered in the Django admin site. + +The parent model +~~~~~~~~~~~~~~~~ + +The parent model needs to inherit ``PolymorphicParentModelAdmin``, and implement the following: + +* ``base_model`` should be set +* ``get_admin_for_model()`` should return the model class for the child model. +* ``get_child_model_classes()`` should return a list of all child model classes. + +The exact implementation can depend on the way your module is structured. +Either a plugin registration system, or configuration setting could be used. +The parent admin redirects it's change and delete views to the child admin. + +The child models +~~~~~~~~~~~~~~~~ + +The admin interface of the derived models should inherit from ``PolymorphicChildModelAdmin``. +Again, ``base_model`` should be set in this class as well. +This class implements the following features: + +* It corrects the breadcrumbs in the admin pages. +* It extends the template lookup paths, to look for both the parent model and child model in the ``admin/app/model/change_form.html`` path. +* It allows to set ``base_form`` so the derived class will automatically include other fields in the form. +* It allows to set ``base_fieldsets`` so the derived class will automatically display any extra fields. + +By adding ``polymorphic`` to the ``INSTALLED_APPS``, the breadcrumbs will be +fixed as well, to stay the same for all child models. + +The standard ``ModelAdmin`` attributes ``form`` and ``fieldsets`` should rather be avoided at the base class, +because it will hide any additional fields which are defined in the derived model. Instead, +use the ``base_form`` and ``base_fieldsets`` instead. The ``PolymorphicChildModelAdmin`` will +automatically detect the additional fields that the child model has, display those in a separate fieldset. + + +Example +~~~~~~~ + +:: + + from django.contrib import admin + from polymorphic.admin import PolymorphicParentModelAdmin, PolymorphicChildModelAdmin + + + class ModelAParentAdmin(PolymorphicParentModelAdmin): + """ The parent model admin """ + base_model = ModelA + + def get_admin_for_model(self, model): + # just `return ModelAChildAdmin` would also work, if you don't customize anything. + return CHILD_ADMINS[model] + + def get_child_model_classes(self, model): + return CHILD_ADMINS.keys() + + + # Only the parent needs to be registered: + admin.site.register(ModelA, ModelAParentAdmin) + + + class ModelAChildAdmin(PolymorphicChildModelAdmin): + """ Base admin class for all child models """ + base_model = ModelA + + # By using these `base_...` attributes instead of the regular ModelAdmin `form` and `fieldsets`, + # the additional fields of the child models are automatically added to the admin form. + base_form = ... + base_fieldsets = ( + ... + ) + + class ModelBAdmin(ModelAChildAdmin): + # define custom features here + + class ModelCAdmin(ModelBAdmin): + # define custom features here + + + # This could be replaced with a registration system: + CHILD_ADMINS = { + ModelB: ModelBAdmin, + ModelC: ModelCAdmin, + } + + Filtering for classes (equivalent to python's isinstance() ): ------------------------------------------------------------- diff --git a/polymorphic/admin.py b/polymorphic/admin.py new file mode 100644 index 0000000..bb53e00 --- /dev/null +++ b/polymorphic/admin.py @@ -0,0 +1,361 @@ +""" +ModelAdmin code to display polymorphic models. +""" +from django import forms +from django.conf.urls.defaults import patterns, url +from django.contrib import admin +from django.contrib.admin.helpers import AdminForm, AdminErrorList +from django.contrib.admin.widgets import AdminRadioSelect +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied +from django.core.urlresolvers import RegexURLResolver +from django.http import Http404, HttpResponseRedirect +from django.shortcuts import render_to_response +from django.template.context import RequestContext +from django.utils.encoding import force_unicode +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext_lazy as _ +import abc + +__all__ = ('PolymorphicModelChoiceForm', 'PolymorphicParentModelAdmin') + + +class PolymorphicModelChoiceForm(forms.Form): + """ + The default form for the ``add_type_form``. Can be overwritten and replaced. + """ + ct_id = forms.ChoiceField(label=_("Type"), widget=AdminRadioSelect(attrs={'class': 'radiolist'})) + + + +class PolymorphicParentModelAdmin(admin.ModelAdmin): + """ + A admin interface that can displays different change/delete pages, depending on the polymorphic model. + To use this class, two methods need to be defined: + + * :func:`get_admin_for_model` should return a ModelAdmin instance for the derived model. + * :func:`get_polymorphic_model_classes` should return the available derived models. + * optionally, :func:`get_polymorphic_type_choices` can be overwritten to refine the choices for the add dialog. + + This class needs to be inherited by the model admin base class that is registered in the site. + The derived models should *not* register the ModelAdmin, but instead it should be returned by :func:`get_admin_for_model`. + """ + base_model = None + add_type_template = None + add_type_form = PolymorphicModelChoiceForm + + + @abc.abstractmethod + def get_admin_for_model(self, model): + """ + Return the polymorphic admin interface for a given model. + """ + raise NotImplementedError("Implement get_admin_for_model()") + + + @abc.abstractmethod + def get_child_model_classes(self): + """ + Return the derived model classes which this admin should handle. + + This could either be implemented as ``base_model.__subclasses__()``, + a setting in a config file, or a query of a plugin registration system. + """ + raise NotImplementedError("Implement get_child_model_classes()") + + + def get_child_type_choices(self): + """ + Return a list of polymorphic types which can be added. + """ + choices = [] + for model in self.get_child_model_classes(): + ct = ContentType.objects.get_for_model(model) + choices.append((ct.id, model._meta.verbose_name)) + return choices + + + def _get_real_admin(self, object_id): + obj = self.model.objects.non_polymorphic().values('polymorphic_ctype').get(pk=object_id) + return self._get_real_admin_by_ct(obj['polymorphic_ctype']) + + + def _get_real_admin_by_ct(self, ct_id): + try: + ct = ContentType.objects.get_for_id(ct_id) + except ContentType.DoesNotExist as e: + raise Http404(e) # Handle invalid GET parameters + + model_class = ct.model_class() + if not model_class: + raise Http404("No model found for '{0}.{1}'.".format(*ct.natural_key())) # Handle model deletion + + # The views are already checked for permissions, so ensure the model is a derived object. + # Otherwise, it would open all admin views to users who can edit the base object. + if not issubclass(model_class, self.base_model): + raise PermissionDenied("Invalid model '{0}.{1}', must derive from {name}.".format(*ct.natural_key(), name=self.base_model.__name__)) + + return self.get_admin_for_model(model_class) + + + def queryset(self, request): + return super(PolymorphicParentModelAdmin, self).queryset(request).non_polymorphic() # optimize the list display. + + + def add_view(self, request, form_url='', extra_context=None): + """Redirect the add view to the real admin.""" + ct_id = int(request.GET.get('ct_id', 0)) + if not ct_id: + # Display choices + return self.add_type_view(request) + else: + real_admin = self._get_real_admin_by_ct(ct_id) + return real_admin.add_view(request, form_url, extra_context) + + + def change_view(self, request, object_id, *args, **kwargs): + """Redirect the change view to the real admin.""" + # between Django 1.3 and 1.4 this method signature differs. Hence the *args, **kwargs + real_admin = self._get_real_admin(object_id) + return real_admin.change_view(request, object_id, *args, **kwargs) + + + def delete_view(self, request, object_id, extra_context=None): + """Redirect the delete view to the real admin.""" + real_admin = self._get_real_admin(object_id) + return real_admin.delete_view(request, object_id, extra_context) + + + def get_urls(self): + """ + Expose the custom URLs for the subclasses and the URL resolver. + """ + urls = super(PolymorphicParentModelAdmin, self).get_urls() + info = self.model._meta.app_label, self.model._meta.module_name + + # Patch the change URL so it's not a big catch-all; allowing all custom URLs to be added to the end. + # The url needs to be recreated, patching url.regex is not an option Django 1.4's LocaleRegexProvider changed it. + new_change_url = url(r'^(\d+)/$', self.admin_site.admin_view(self.change_view), name='{0}_{1}_change'.format(*info)) + for i, oldurl in enumerate(urls): + if oldurl.name == new_change_url.name: + urls[i] = new_change_url + + # Define the catch-all for custom views + custom_urls = patterns('', + url(r'^(?P.+)$', self.admin_site.admin_view(self.subclass_view)) + ) + + # Add reverse names for all polymorphic models, so the delete button and "save and add" just work. + # These definitions are masked by the definition above, since it needs special handling (and a ct_id parameter). + from fluent_pages.extensions import page_type_pool + dummy_urls = [] + for model in page_type_pool.get_model_classes(): + admin = self.get_admin_for_model(model) + dummy_urls += admin.get_urls() + + return urls + custom_urls + dummy_urls + + + def subclass_view(self, request, path): + """ + Forward any request to a custom view of the real admin. + """ + ct_id = int(request.GET.get('ct_id', 0)) + if not ct_id: + raise Http404("No ct_id parameter, unable to find admin subclass for path '{0}'.".format(path)) + + real_admin = self._get_real_admin_by_ct(ct_id) + resolver = RegexURLResolver('^', real_admin.urls) + resolvermatch = resolver.resolve(path) + if not resolvermatch: + raise Http404("No match for path '{0}' in admin subclass.".format(path)) + + return resolvermatch.func(request, *resolvermatch.args, **resolvermatch.kwargs) + + + def add_type_view(self, request, form_url=''): + """ + Display a choice form to select which page type to add. + """ + extra_qs = '' + if request.META['QUERY_STRING']: + extra_qs = '&' + request.META['QUERY_STRING'] + + choices = self.get_child_type_choices() + if len(choices) == 1: + return HttpResponseRedirect('?ct_id={0}{1}'.format(choices[0][0], extra_qs)) + + # Create form + form = self.add_type_form( + data=request.POST if request.method == 'POST' else None, + initial={'ct_id': choices[0][0]} + ) + form.fields['ct_id'].choices = choices + + if form.is_valid(): + return HttpResponseRedirect('?ct_id={0}{1}'.format(form.cleaned_data['ct_id'], extra_qs)) + + # Wrap in all admin layout + fieldsets = ((None, {'fields': ('ct_id',)}),) + adminForm = AdminForm(form, fieldsets, {}, model_admin=self) + media = self.media + adminForm.media + opts = self.model._meta + + context = { + 'title': _('Add %s') % force_unicode(opts.verbose_name), + 'adminform': adminForm, + 'is_popup': "_popup" in request.REQUEST, + 'media': mark_safe(media), + 'errors': AdminErrorList(form, ()), + 'app_label': opts.app_label, + } + return self.render_add_type_form(request, context, form_url) + + + def render_add_type_form(self, request, context, form_url=''): + """ + Render the page type choice form. + """ + opts = self.model._meta + app_label = opts.app_label + context.update({ + 'has_change_permission': self.has_change_permission(request), + 'form_url': mark_safe(form_url), + 'opts': opts, + }) + if hasattr(self.admin_site, 'root_path'): + context['root_path'] = self.admin_site.root_path # Django < 1.4 + context_instance = RequestContext(request, current_app=self.admin_site.name) + return render_to_response(self.add_type_template or [ + "admin/%s/%s/add_type_form.html" % (app_label, opts.object_name.lower()), + "admin/%s/add_type_form.html" % app_label, + "admin/add_type_form.html" + ], context, context_instance=context_instance) + + + +class PolymorphicChildModelAdmin(admin.ModelAdmin): + """ + The *optional* base class for the admin interface of derived models. + + This base class defines some convenience behavior for the admin interface: + + * It corrects the breadcrumbs in the admin pages. + * It adds the base model to the template lookup paths. + * It allows to set ``base_form`` so the derived class will automatically include other fields in the form. + * It allows to set ``base_fieldsets`` so the derived class will automatically display any extra fields. + + The ``base_model`` attribute must be set. + """ + base_model = None + base_form = None + base_fieldsets = None + extra_fieldset_title = _("Contents") # Default title for extra fieldset + + + def get_form(self, request, obj=None, **kwargs): + # The django admin validation requires the form to have a 'class Meta: model = ..' + # attribute, or it will complain that the fields are missing. + # However, this enforces all derived ModelAdmin classes to redefine the model as well, + # because they need to explicitly set the model again - it will stick with the base model. + # + # Instead, pass the form unchecked here, because the standard ModelForm will just work. + # If the derived class sets the model explicitly, respect that setting. + if not self.form: + kwargs['form'] = self.base_form + return super(PolymorphicChildModelAdmin, self).get_form(request, obj, **kwargs) + + + @property + def change_form_template(self): + opts = self.model._meta + app_label = opts.app_label + + # Pass the base options + base_opts = self.base_model._meta + base_app_label = base_opts.app_label + + return [ + "admin/%s/%s/change_form.html" % (app_label, opts.object_name.lower()), + "admin/%s/change_form.html" % app_label, + # Added: + "admin/%s/%s/change_form.html" % (base_app_label, base_opts.object_name.lower()), + "admin/%s/change_form.html" % base_app_label, + "admin/polymorphic/change_form.html", + "admin/change_form.html" + ] + + + @property + def delete_confirmation_template(self): + opts = self.model._meta + app_label = opts.app_label + + # Pass the base options + base_opts = self.base_model._meta + base_app_label = base_opts.app_label + + return [ + "admin/%s/%s/delete_confirmation.html" % (app_label, opts.object_name.lower()), + "admin/%s/delete_confirmation.html" % app_label, + # Added: + "admin/%s/%s/delete_confirmation.html" % (base_app_label, base_opts.object_name.lower()), + "admin/%s/delete_confirmation.html" % base_app_label, + "admin/polymorphic/delete_confirmation.html", + "admin/delete_confirmation.html" + ] + + + def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): + context.update({ + 'base_opts': self.base_model._meta, + }) + return super(PolymorphicChildModelAdmin, self).render_change_form(request, context, add=add, change=change, form_url=form_url, obj=obj) + + + def delete_view(self, request, object_id, context=None): + extra_context = { + 'base_opts': self.base_model._meta, + } + return super(PolymorphicChildModelAdmin, self).delete_view(request, object_id, extra_context) + + + # ---- Extra: improving the form/fieldset default display ---- + + def get_fieldsets(self, request, obj=None): + # If subclass declares fieldsets, this is respected + if self.declared_fieldsets: + return super(PolymorphicChildModelAdmin, self).get_fieldsets(request, obj) + + # Have a reasonable default fieldsets, + # where the subclass fields are automatically included. + other_fields = self.get_subclass_fields(request, obj) + + if other_fields: + return ( + self.base_fieldsets[0], + (self.extra_fieldset_title, {'fields': other_fields}), + ) + self.base_fieldsets[1:] + else: + return self.base_fieldsets + + + def get_subclass_fields(self, request, obj=None): + # Find out how many fields would really be on the form, + # if it weren't restricted by declared fields. + exclude = list(self.exclude or []) + exclude.extend(self.get_readonly_fields(request, obj)) + + # By not declaring the fields/form in the base class, + # get_form() will populate the form with all available fields. + form = self.get_form(request, obj, exclude=exclude) + subclass_fields = form.base_fields.keys() + list(self.get_readonly_fields(request, obj)) + + # Find which fields are not part of the common fields. + for fieldset in self.base_fieldsets: + for field in fieldset[1]['fields']: + try: + subclass_fields.remove(field) + except ValueError: + pass # field not found in form, Django will raise exception later. + return subclass_fields diff --git a/polymorphic/templates/admin/polymorphic/change_form.html b/polymorphic/templates/admin/polymorphic/change_form.html new file mode 100644 index 0000000..7e2bf53 --- /dev/null +++ b/polymorphic/templates/admin/polymorphic/change_form.html @@ -0,0 +1,15 @@ +{% extends "admin/change_form.html" %} +{% load i18n polymorphic_admin_tags %} + +{% block breadcrumbs %}{% if not is_popup %}{% breadcrumb_scope base_opts %} + +{% endbreadcrumb_scope %}{% endif %}{% endblock %} + diff --git a/polymorphic/templates/admin/polymorphic/delete_confirmation.html b/polymorphic/templates/admin/polymorphic/delete_confirmation.html new file mode 100644 index 0000000..d0e69ba --- /dev/null +++ b/polymorphic/templates/admin/polymorphic/delete_confirmation.html @@ -0,0 +1,15 @@ +{% extends "admin/delete_confirmation.html" %} +{% load i18n polymorphic_admin_tags %} + +{% block breadcrumbs %}{% breadcrumb_scope base_opts %} + +{% endbreadcrumb_scope %}{% endblock %} diff --git a/polymorphic/templatetags/__init__.py b/polymorphic/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polymorphic/templatetags/polymorphic_admin_tags.py b/polymorphic/templatetags/polymorphic_admin_tags.py new file mode 100644 index 0000000..bf645bf --- /dev/null +++ b/polymorphic/templatetags/polymorphic_admin_tags.py @@ -0,0 +1,52 @@ +from django.template import Library, Node, TemplateSyntaxError + +register = Library() + + +class BreadcrumbScope(Node): + def __init__(self, base_opts, nodelist): + self.base_opts = base_opts + self.nodelist = nodelist # Note, takes advantage of Node.child_nodelists + + @classmethod + def parse(cls, parser, token): + bits = token.split_contents() + if len(bits) == 2: + (tagname, base_opts) = bits + base_opts = parser.compile_filter(base_opts) + nodelist = parser.parse(('endbreadcrumb_scope',)) + parser.delete_first_token() + + return cls( + base_opts=base_opts, + nodelist=nodelist + ) + else: + raise TemplateSyntaxError("{0} tag expects 1 argument".format(token.contents[0])) + + + def render(self, context): + # app_label is really hard to overwrite in the standard Django ModelAdmin. + # To insert it in the template, the entire render_change_form() and delete_view() have to copied and adjusted. + # Instead, have an assignment tag that inserts that in the template. + base_opts = self.base_opts.resolve(context) + new_vars = {} + if base_opts and not isinstance(base_opts, basestring): + new_vars = { + 'app_label': base_opts.app_label, # What this is all about + 'opts': base_opts, + } + + new_scope = context.push() + new_scope.update(new_vars) + html = self.nodelist.render(context) + context.pop() + return html + + +@register.tag +def breadcrumb_scope(parser, token): + """ + Easily allow the breadcrumb to be generated in the admin change templates. + """ + return BreadcrumbScope.parse(parser, token)