Customizing FeinCMS Part 2: First-Level-Only templates

Part 2 of the "Customizing FeinCMS" series. In this post we are going to add an important feature: First-Lavel-Only templates.

Table Of Contents

You can find the code used in this serie on github.

Why

It's common to have templates valid only for first-level pages, an essential feature if you don't want your clients to break the design of your website.

To understand how important it is, think of a home-page or a contact-form; you really don't want a client to use this kind of templates for subpages.

What we need to do in a nutshell

As you have seen in the first part of this series, we added a simple but flexible logic based on template validation. We defined a custom exception for our custom feature and a few functions to validate the page form, we also excluded invalid templates from the form so that the user wouldn't think that he was doing something wrong.

In this post, we are going to use the same design for First-Level-Only templates by adding an additional exception and a few lines of code for validating a page. Afterwards, we will see how to improve the user experience by styling error messages and keeping the CMS in a consistent state.

Implementation

Our custom Template needs an extra argument for first-level templates.

pages.models:

from feincms.models import Base, Template as FeinCMSTemplate
from feincms.module.page.models import Page

class Template(FeinCMSTemplate):
    def __init__(
        self, title, path, regions, key=None, preview_image=None, unique=False,
        first_level_only=False
    ):
        super(Template, self).__init__(
            title, path, regions, key=key, preview_image=preview_image
        )
        self.unique = unique
        self.first_level_only = first_level_only

Now we can specify that the homepage is a first-level-only template whilst registering it.

pages.models:

Page.register_templates(Template(
    key='homepage',
    title='Home Page',
    path='pages/home_page.html',
    regions=(
        ('home_main', 'Contenuto Principale'),
    ),
    unique=True,
    first_level_only=True
))

As said earlier, we need to create a new exception for our new feature

pages.exceptions:

class FirstLevelOnlyTemplateException(Exception):
    pass

and update the validation code by checking that first-level pages aren't used as subpages.

pages.admin:

from django.contrib import admin
from django.conf import settings as django_settings
from django.utils.translation import ugettext_lazy as _
from django.utils.safestring import mark_safe
from django.forms.util import ErrorList
from django.http import HttpResponse

from feincms.module.page.models import Page, PageAdmin as PageAdminOld
from feincms.module.page.models import PageAdminForm as PageAdminFormOld

from pages.exceptions import UniqueTemplateException
from pages.exceptions import FirstLevelOnlyTemplateException


def check_template(model, template, instance=None, parent=None):
    if template.unique and model.objects.filter(
                        template_key=template.key
                    ).exclude(id=instance.id if instance else -1).count():
        raise UniqueTemplateException()

    if template.first_level_only and parent:
        raise FirstLevelOnlyTemplateException()

def is_template_valid(model, template, instance=None, parent=None):
    try:
        check_template(model, template, instance=instance, parent=parent)
        return True
    except (
            UniqueTemplateException, FirstLevelOnlyTemplateException
        ):
        pass

    return False


class PageAdminForm(PageAdminFormOld):
    def __init__(self, *args, **kwargs):
        super(PageAdminForm, self).__init__(*args, **kwargs)

        instance = kwargs.get('instance')
        parent = kwargs.get('initial', {}).get('parent')
        if not parent and instance:
            parent = instance.parent
        templates = self.get_valid_templates(instance, parent)

        choices = []
        for key, template in templates.items():
            if template.preview_image:
                choices.append((template.key,
                    mark_safe(u'<img src="%s" alt="%s" /> %s' % (
                        template.preview_image, template.key, template.title))))
            else:
                choices.append((template.key, template.title))

        self.fields['template_key'].choices = choices
        if choices:
            self.fields['template_key'].default = choices[0][0]

    def clean(self):
        cleaned_data = super(PageAdminForm, self).clean()

        # No need to think further, let the user correct errors first
        if self._errors:
            return cleaned_data

        parent = cleaned_data.get('parent')
        if parent:
            template_key = cleaned_data['template_key']
            template = self.Meta.model._feincms_templates[template_key]

            try:
                check_template(
                    self.Meta.model, template, instance=self.instance, parent=parent
                )
            except UniqueTemplateException:
                self._errors['parent'] = ErrorList(
                    [_('Template already used somewhere else.')]
                )
                del cleaned_data['parent']
            except FirstLevelOnlyTemplateException:
                self._errors['parent'] = ErrorList(
                    [_("This template can't be used as a subpage")]
                )
                del cleaned_data['parent']
        return cleaned_data


    def get_valid_templates(self, instance=None, parent=None):
        """
            @return dict: dict containing all the templates valid for this instance
                (excluding unique ones already used etc.)
        """
        templates = self.Meta.model._feincms_templates.copy()

        return dict(
            filter(
                lambda (key, template): is_template_valid(
                    self.Meta.model, template, instance=instance, parent=parent
                ), templates.items()
            )
        )

Just a few new updates:

Now, if you navigate to the change_list you might notice that you can arrange pages by clicking on the Cut icon and Paste them wherever you want. At the moment, you can move First-Level-Only pages around and use them as subpages, this is definitely not acceptable so we need some extra validation.

FeinCMS uses an ajax request for cutting and pasting pages calling PageAdmin._move_node, we are going to extend it by checking if we can really move the page and returning an error if not.

To add this validation, we can call check_template and see if it raises an exception, if so it means that the user is not allowed to move the page and we alert him with an error message.

pages.admin:

class PageAdmin(PageAdminOld):
    form = PageAdminForm

    def _move_node(self, request):
        cut_item = self.model._tree_manager.get(pk=request.POST.get('cut_item'))
        pasted_on = self.model._tree_manager.get(pk=request.POST.get('pasted_on'))
        position = request.POST.get('position')

        if position == 'last-child':
            cut_item_template = self.model._feincms_templates[cut_item.template_key]
            pasted_on_template = self.model._feincms_templates[pasted_on.template_key]

            try:
                check_template(
                    self.model, cut_item_template, instance=cut_item, parent=pasted_on
                )
            except FirstLevelOnlyTemplateException:
                return HttpResponse(unicode(_(u"This page can't be used as subpage.")))
            except:
                return HttpResponse(unicode(_(u"Server Error")))

        return super(PageAdmin, self)._move_node(request)

Finally, we can unregister Page and register it again using our PageAdmin definition.

# We have to unregister it, and then reregister
admin.site.unregister(Page)
admin.site.register(Page, PageAdmin)

Almost done, we now have server side validation for change_form and change_list, you can play around with them and see that it's exactly what we wanted.

The last thing to do is to style error messages in the change_list so that they appear exactly like standard django ones.

media.admin.feincms.page_toolbox.js:

function paste_item(pk, position) {
    if(!cut_item_pk)
        return false;

    $.post('.', {
            '__cmd': 'move_node',
            'position': position,
            'cut_item': cut_item_pk,
            'pasted_on': pk
        }, function(data) {
            if(data == 'OK') {
                window.location.reload();
            } else {
                if (!$('#changelist .errornote').length) {
                    $('<p class="errornote"></p>').hide().prependTo('#changelist');
                }
                $('#changelist .errornote').text(data).fadeIn();

                setTimeout("$('.errornote').fadeOut()", 5000);
            }
        });

    return false;
}

UPDATE 10.2012

In new versions of Django and FeinCMS you might find that the page_toolbox.js has disappeared. In this case you can take advantage of the Django new messages framework and remove the js file entirely.

You do have to add a few extra lines in your pages.admin file though to pass the error message to the user.

from django.contrib import messages
...


class PageAdmin(PageAdminOld):
    form = PageAdminForm

    def _move_node(self, request):
        cut_item = self.model._tree_manager.get(pk=request.POST.get('cut_item'))
        pasted_on = self.model._tree_manager.get(pk=request.POST.get('pasted_on'))
        position = request.POST.get('position')

        if position == 'last-child':
            cut_item_template = self.model._feincms_templates[cut_item.template_key]
            pasted_on_template = self.model._feincms_templates[pasted_on.template_key]

            try:
                check_template(
                    self.model, cut_item_template, instance=cut_item, parent=pasted_on
                )
            except FirstLevelOnlyTemplateException:
                msg = unicode(_(u"This page can't be used as subpage."))
                messages.error(request, msg)
                return HttpResponse(msg)
            except:
                msg = unicode(_(u"Server Error"))
                messages.error(request, msg)
                return HttpResponse(msg)

        return super(PageAdmin, self)._move_node(request)

I've only included the messages module and used it in case of errors.

Conclusions

As you can see good software design might help you improve your code whilst adding new features.
It's amazing what you can do with a couple of functions and a few extra lines of code.

We can now create our unique Home Page and don't allow it to be used as subpage.

Next

Customizing FeinCMS Part 3: No-Children templates

17 June 2010