Sviluppo web

django CMS Page and Title extension

Page is a Page is a Page is a Page

The other day, while discussing designs with a client, he came up with the requirement of having author information on each page of his website. It was a good idea, but how could we implement that? 
Using a dedicated plugin in each page would be feasible but definitely cumbersome.
To handle such cases a common pattern in the CMS world is to have a very broad definition of what a page is. A Page object is often just a base to create more complex objects (commonly named 'Content type' or 'Classes') by adding attributes and properties, while still remaining a Page object managed by the CMS. In this case we could create an 'AuthorPage' (or whatever) with a link to an Author object. django CMS took a very different approach and stayed true to this choice ever since. Page class is unique, and you're not supposed to mess with it.

The django CMS way

django CMS is a good Django citizen in the first place and tries to do a great job at one thing i.e. being a CMS and managing unstructured content, while leaving tasks such as taking care of structured content, user interaction and so on  to specialized Django applications. In other words, if you want a list of news, don't try to organize pages to emulate a list of news: write a couple dozen lines of Python, and you'll end up with a far better news application than a bunch of pages could ever be!

Extending the CMS Page

Nevertheless sometimes the need to add some information to the Page model arise, without really making it other than a Page, classification tags, authorship information, or extended meta tags, just some examples of information that may be needed to be attached to any page. Up until now there wasn't any cleaner way to achieve this, than writing a bunch of hackish code. Since 3.0 this has changed for the better. The Page Extensions API permits adding attributes to Page and Title objects, augmenting the storable information for each that can be later retrieved in the template or everywhere in your Django project. The good thing is, everything is totally transparent and django-ish: PageExtension and TitleExtension (the base classes you're going to use) are just models with a OneToOneField to Page or Title models, and most of the work the API code does is, keeping the state sane when you populate those models or publish pages. Accessing a PageAuthor instance, for example in the template is something like request.current_page.pageauthor.author with no real magic behind the curtains (if you except the Django ORM magic!)

Implementing an extension

Extension are recommended to live in a separate application, especially if they have relations with other models. To implement a Page / Title extension you must create three classes:

  • the extension model: it's where the extra information are going to be stored;
  • the extension admin: it's the ModelAdmin for the model;
  • the extension toolbar items: the extension admin is not “usable” from the main admin dashboard and you must tie it to the toolbar for it to work properly.

Extension model - PageAuthorProperties

The extension model is a model extending from a defined parent that handles the publishing process and other internal working; it must be defined in models.py.
In this way, the resulting model is quite simple and straightforward:

# -*- coding: utf-8 -*-
from django.contrib.auth.models import User
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from cms.extensions import PageExtension, extension_pool


@python_2_unicode_compatible
class PageAuthorProperties(PageExtension):
    author = models.ForeignKey(User, null=True, blank=True,
                               verbose_name=_(u'Page author'), related_name='page_author')

    def __str__(self):
        if self.author:
            return _('Page author %s') % self.author.get_full_name()
        else:
            return _('No author')
extension_pool.register(PageAuthorProperties)

Basically the only thing you need are the fields that you want in your page extension; you can add any number of field of any type.

After the model definition you must register your model as a page extension with extension_pool.register.

If you add ManyToMany fields in your model, you are requested to define a copy_relations(self) method to handle copy related objects when publishing the extension; see documentation for examples.

ModelAdmin

This is implemented in admin.py as always; if you don't need ModelAdmin customisation, registering the model with the generic PageExtensionAdmin will suffice:

# -*- coding: utf-8 -*-
from cms.extensions import PageExtensionAdmin
from django.contrib import admin

from .models import PageAuthorProperties

admin.site.register(PageAuthorProperties, PageExtensionAdmin)

Otherwise just create a ModelAdmin by extending PageExtensionAdmin:

# -*- coding: utf-8 -*-
from cms.extensions import PageExtensionAdmin
from django.contrib import admin

from .models import PageAuthorProperties

class PageAuthorPropertiesAdmin(PageExtensionAdmin):
    fields = ('author',)

admin.site.register(PageAuthorProperties, PageAuthorPropertiesAdmin)

Toolbar - PageAuthorToolbar

Toolbar is the way django CMS allows you to customise your editing interface.

Implementing a toolbar is a bit a low level thing as you are required to check for the permissions and add the proper toolbar item in the desired position. Toolbar must by defined in cms_toolbar.py file.

This is the complete code for a page extension toolbar:

# -*- coding: utf-8 -*-
from cms.api import get_page_draft
from cms.toolbar_pool import toolbar_pool
from cms.toolbar_base import CMSToolbar
from cms.utils import get_cms_setting
from cms.utils.permissions import has_page_change_permission
from django.core.urlresolvers import reverse, NoReverseMatch
from django.utils.translation import ugettext_lazy as _
from .models import PageAuthorProperties


@toolbar_pool.register
class PageAuthorPropertiesToolbar(CMSToolbar):
    def populate(self):
        # always use draft if we have a page
        self.page = get_page_draft(self.request.current_page)

        if not self.page:
            # Nothing to do
            return

        # check global permissions if CMS_PERMISSIONS is active
        if get_cms_setting('PERMISSION'):
            has_global_current_page_change_permission = has_page_change_permission(self.request)
        else:
            has_global_current_page_change_permission = False
            # check if user has page edit permission
        can_change = self.request.current_page and self.request.current_page.has_change_permission(self.request)
        if has_global_current_page_change_permission or can_change:
            try:
                extension = PageAuthorProperties.objects.get(extended_object_id=self.page.id)
            except PageAuthorProperties.DoesNotExist:
                extension = None
            try:
                if extension:
                    url = reverse('admin:myapp_pageauthorproperties_change', args=(extension.pk,))
                else:
                    url = reverse('admin:myapp_pageauthorproperties_add') + '?extended_object=%s' % self.page.pk
            except NoReverseMatch:
                # not in urls
                pass
            else:
                not_edit_mode = not self.toolbar.edit_mode
                current_page_menu = self.toolbar.get_or_create_menu('page')
                current_page_menu.add_modal_item(_('Page author'), url=url, disabled=not_edit_mode)

Additional notes

When you add an item to the toolbar you have to check whether the toolbar is

in edit mode or not and eventually disable the menu item:

current_page_menu.add_modal_item('page author', url=url, disabled=not self.toolbar.edit_mode, position=position)

Another option is to completely remove the item if the toolbar is in live mode:

if url and self.toolbar.edit_mode:
    current_page_menu.add_modal_item('page author', url=url, position=position)