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.
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:
check_template
checks if the First-Level-Only Template can be assigned to the pageis_valid_template
catchesFirstLevelOnlyTemplateException
along withUniqueTemplateException
PageAdminForm.clean
invalidate the form when the First-Level-Only template specified by the user can't be assigned to the page
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