From 90021bc9f07a4c2d3da2c74ee5bbd4e3fd874947 Mon Sep 17 00:00:00 2001 From: bkfox Date: Sun, 24 Jul 2016 19:49:15 +0200 Subject: [PATCH] sections rendering + make view for existing section's items --- cms/models.py | 4 +- cms/sections.py | 373 ++++++++++++++---- cms/templates/cms/base_site.html | 6 +- cms/templates/cms/sections/section_item.html | 8 + cms/templates/cms/sections/section_link.html | 13 + .../cms/sections/section_link_list.html | 16 + cms/templates/cms/sections/section_list.html | 17 + cms/templates/cms/snippets/date_list.html | 2 +- cms/templates/cms/snippets/list_item.html | 9 +- cms/templatetags/aircox_cms.py | 24 +- controllers/models.py | 2 +- notes.md | 13 + programs/models.py | 2 +- 13 files changed, 395 insertions(+), 94 deletions(-) create mode 100644 cms/templates/cms/sections/section_item.html create mode 100644 cms/templates/cms/sections/section_link.html create mode 100644 cms/templates/cms/sections/section_link_list.html create mode 100644 cms/templates/cms/sections/section_list.html diff --git a/cms/models.py b/cms/models.py index c2bbeb0..8d4d285 100644 --- a/cms/models.py +++ b/cms/models.py @@ -396,7 +396,7 @@ class DiffusionPage(Publication): Return a DiffusionPage or ListItem from a Diffusion """ if diff.page.all().count(): - item = diff.page.live().first() + item = diff.page.all().first() else: item = ListItem( title = '{}, {}'.format( @@ -493,7 +493,7 @@ class ListPage(Page): def get_context(self, request, *args, **kwargs): context = super().get_context(request, *args, **kwargs) - qs = ListBase.get_queryset_from_request(request, context=context) + qs = ListBase.from_request(request, context=context) context['object_list'] = qs return context diff --git a/cms/sections.py b/cms/sections.py index a74b5f0..a7c3b36 100644 --- a/cms/sections.py +++ b/cms/sections.py @@ -1,11 +1,11 @@ import datetime import re -from enum import Enum, IntEnum - +from enum import IntEnum from django.db import models from django.utils.translation import ugettext as _, ugettext_lazy from django.utils import timezone as tz +from django.core.urlresolvers import reverse from django.template.loader import render_to_string from django.contrib.contenttypes.models import ContentType from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger @@ -23,6 +23,8 @@ from wagtail.wagtailcore.utils import camelcase_to_underscore from wagtail.wagtailsnippets.edit_handlers import SnippetChooserPanel from wagtail.wagtailsnippets.models import register_snippet +from wagtail.wagtailimages.utils import generate_signature + # tags from modelcluster.models import ClusterableModel from modelcluster.fields import ParentalKey @@ -30,27 +32,28 @@ from modelcluster.tags import ClusterTaggableManager from taggit.models import TaggedItemBase -def related_pages_filter(): +def related_pages_filter(reset_cache=False): """ Return a dict that can be used to filter foreignkey to pages' subtype declared in aircox.cms.models. - Note: the filter is calculated at the first call of the function. + This value is stored in cache, but it is possible to reset the + cache using the `reset_cache` parameter. """ - if hasattr(related_pages_filter, 'filter'): - return related_pages_filter.filter + if not reset_cache and hasattr(related_pages_filter, 'cache'): + return related_pages_filter.cache import aircox.cms.models as cms import inspect - related_pages_filter.filter = { - 'model__in': (name.lower() for name, member in + related_pages_filter.cache = { + 'model__in': list(name.lower() for name, member in inspect.getmembers(cms, lambda x: inspect.isclass(x) and issubclass(x, Page) ) if member != Page ), } - return related_pages_filter.filter + return related_pages_filter.cache class ListItem: @@ -113,6 +116,21 @@ class RelatedLinkBase(Orderable): ], heading=_('link')) ] + def as_dict(self): + """ + Return compiled values from parameters as dict with + 'url', 'icon', 'text' + """ + if self.page: + url, text = self.page.url, self.page.title + else: + url, text = self.url, self.url + return { + 'url': url, + 'text': self.text or text, + 'icon': self.icon + } + class ListBase(models.Model): """ @@ -125,96 +143,128 @@ class ListBase(models.Model): before_related = 0x03, after_related = 0x04, - filter_date = models.SmallIntegerField( + date_filter = models.SmallIntegerField( verbose_name = _('filter by date'), choices = [ (int(y), _(x.replace('_', ' '))) for x,y in DateFilter.__members__.items() ], blank = True, null = True, ) - filter_model = models.ForeignKey( + model = models.ForeignKey( ContentType, verbose_name = _('filter by type'), blank = True, null = True, help_text = _('if set, select only elements that are of this type'), limit_choices_to = related_pages_filter, ) - filter_related = models.ForeignKey( - 'Publication', + related = models.ForeignKey( + Page, verbose_name = _('filter by a related publication'), blank = True, null = True, - help_text = _('if set, select children or siblings of this publication'), + help_text = _('if set, select children or siblings related to this page'), ) - related_sibling = models.BooleanField( - verbose_name = _('related is a sibling'), + siblings = models.BooleanField( + verbose_name = _('select siblings of related'), default = False, help_text = _('if selected select related publications that are ' 'siblings of this one'), ) - sort_asc = models.BooleanField( - verbose_name = _('order ascending'), + asc = models.BooleanField( + verbose_name = _('ascending order'), default = True, help_text = _('if selected sort list in the ascending order by date') ) panels = [ MultiFieldPanel([ - FieldPanel('filter_model'), - FieldPanel('filter_related'), - FieldPanel('related_sibling'), + FieldPanel('model'), + PageChooserPanel('related'), + FieldPanel('siblings'), ], heading=_('filters')), MultiFieldPanel([ - FieldPanel('filter_date'), - FieldPanel('sort_asc'), + FieldPanel('date_filter'), + FieldPanel('asc'), ], heading=_('sorting')) ] class Meta: abstract = True - @classmethod - def get_base_queryset(cl, filter_date = None, filter_model = None, - filter_related = None, related_siblings = None, - sort_asc = True): + def get_queryset(self): """ Get queryset based on the arguments. This class is intended to be reusable by other classes if needed. """ + from aircox.cms.models import Publication + related = self.related and self.related.specific() + # model - if filter_model: - qs = filter_model.objects.all() + if self.model: + qs = self.model.model_class().objects.all() else: qs = Publication.objects.all() - qs = qs.live().not_in_section() + qs = qs.live().not_in_menu() # related - if filter_related: - if related_siblings: - qs = qs.sibling_of(filter_related) + if related: + if self.siblings: + qs = qs.sibling_of(related) else: - qs = qs.descendant_of(filter_related) + qs = qs.descendant_of(related) - date = filter_related.date - if filter_date == cl.DateFilter.before_related: + date = self.related.date if hasattr('date', related) else \ + self.related.first_published_at + if self.date_filter == self.DateFilter.before_related: qs = qs.filter(date__lt = date) - elif filter_date == cl.DateFilter.after_related: + elif self.date_filter == self.DateFilter.after_related: qs = qs.filter(date__gte = date) # date else: date = tz.now() - if filter_date == cl.DateFilter.previous: + if self.date_filter == self.DateFilter.previous: qs = qs.filter(date__lt = date) - elif filter_date == cl.DateFilter.next: + elif self.date_filter == self.DateFilter.next: qs = qs.filter(date__gte = date) # sort - if sort_asc: + if self.asc: return qs.order_by('date', 'pk') return qs.order_by('-date', '-pk') + def to_url(self, list_page = None, **kwargs): + """ + Return a url parameters from self. Extra named parameters are used + to override values of self or add some to the parameters. + + If there is related field use it to get the page, otherwise use the + given list_page or the first ListPage it finds. + """ + import aircox.cms.models as models + + params = { + 'date_filter': self.get_date_filter_display(), + 'model': self.model and self.model.model, + 'asc': self.asc, + 'related': self.related, + 'siblings': self.siblings, + } + params.update(kwargs) + + page = params.get('related') or list_page or \ + models.ListPage.objects.all().first() + + if params.get('related'): + params['related'] = True + + params = '&'.join([ + key if value == True else '{}={}'.format(key, value) + for key, value in params.items() + if value + ]) + return page.url + '?' + params + @classmethod - def get_queryset_from_request(cl, request, *args, - related = None, context = {}, - **kwargs): + def from_request(cl, request, related = None, context = None, + *args, **kwargs): """ Return a queryset from the request's GET parameters. Context can be used to update relative informations. @@ -222,10 +272,12 @@ class ListBase(models.Model): This function can be used by other views if needed Parameters: + * date_filter: one of DateFilter attribute's key. * model: ['program','diffusion','event'] type of the publication * asc: if present, sort ascending instead of descending * related: children of the thread passed in arguments only * siblings: sibling of the related instead of children + * tag: tag to search for * search: query to search in the publications * page: page number @@ -236,17 +288,29 @@ class ListBase(models.Model): arguments passed to ListBase.get_base_queryset * paginator: paginator object """ + def set(key, value): + if context is not None: + context[key] = value + + date_filter = request.GET.get('date_filter') model = request.GET.get('model') kwargs = { - 'filter_model': ProgramPage if model == 'program' else \ - DiffusionPage if model == 'diffusion' else \ - EventPage if model == 'event' else None, - 'filter_related': 'related' in request.GET and related, - 'related_siblings': 'siblings' in request.GET, - 'sort_asc': 'asc' in request.GET, + 'date_filter': + int(getattr(cl.DateFilter, date_filter)) + if date_filter and hasattr(cl.DateFilter, date_filter) + else None, + 'model': + ProgramPage if model == 'program' else + DiffusionPage if model == 'diffusion' else + EventPage if model == 'event' else None, + 'related': 'related' in request.GET and related, + 'siblings': 'siblings' in request.GET, + 'asc': 'asc' in request.GET, } - qs = cl.get_base_queryset(**kwargs) + + base_list = cl(**{ k:v for k,v in kwargs.items() if v }) + qs = base_list.get_queryset() # filter by tag tag = request.GET.get('tag') @@ -260,7 +324,7 @@ class ListBase(models.Model): kwargs['terms'] = search qs = qs.search(search) - context['list_selector'] = kwargs + set('list_selector', kwargs) # paginator if qs: @@ -271,8 +335,8 @@ class ListBase(models.Model): qs = paginator.page(1) except EmptyPage: qs = parginator.page(paginator.num_pages) - context['paginator'] = paginator - context['object_list'] = qs + set('paginator', paginator) + set('object_list', qs) return qs @@ -405,10 +469,26 @@ class Section(ClusterableModel): def __str__(self): return '{}: {}'.format(self.__class__.__name__, self.name or self.pk) + @classmethod + def get_sections_at (cl, position, page = None): + """ + Return a queryset of sections that are at the given position. + Filter out Section that are not for the given page. + """ + qs = Section.objects.filter(position = position) + if page: + qs = qs.filter( + models.Q(model__isnull = True) | + models.Q( + model = ContentType.objects.get_for_model(page).pk + ) + ) + return qs + def render(self, request, page = None, *args, **kwargs): return ''.join([ - place.item.render(request, page, *args, **kwargs) - for place in self.places + place.item.specific().render(request, page, *args, **kwargs) + for place in self.places.all() ]) @@ -419,26 +499,42 @@ class SectionPlace(Orderable): verbose_name=_('item') ) - panels = [ - SnippetChooserPanel('item'), - FieldPanel('model'), - ] + panels = [ SnippetChooserPanel('item'), ] def __str__(self): return '{}: {}'.format(self.__class__.__name__, self.title or self.pk) class SectionItemMeta(models.base.ModelBase): + """ + Metaclass for SectionItem, assigning needed values such as `template`. + + It needs to load the item's template if the section uses the default + one, and throw error if there is an error in the template. + """ def __new__(cls, name, bases, attrs): - cl = super().__new__(name, bases, attrs) + from django.template.loader import get_template + from django.template import TemplateDoesNotExist + + cl = super().__new__(cls, name, bases, attrs) if not 'template' in attrs: - attrs['template'] = 'cms/sections/{}.html' \ - .format(camelcase_to_underscore(name)) + cl.template = '{}/sections/{}.html'.format( + cl._meta.app_label, + camelcase_to_underscore(name), + ) + if name != 'SectionItem': + try: + get_template(cl.template) + except TemplateDoesNotExist: + cl.template = 'cms/sections/section_item.html' return cl @register_snippet -class SectionItem(models.Model): +class SectionItem(models.Model,metaclass=SectionItemMeta): + """ + Base class for a section item. + """ real_type = models.CharField( max_length=32, blank = True, null = True, @@ -453,8 +549,8 @@ class SectionItem(models.Model): default = False, help_text=_('if set show a title at the head of the section'), ) - related = models.BooleanField( - _('related'), + is_related = models.BooleanField( + _('is related'), default = False, help_text=_('if set, section is related to the current publication. ' 'e.g rendering a list related to it instead of to all ' @@ -492,17 +588,34 @@ class SectionItem(models.Model): return super().save(*args, **kwargs) def get_context(self, request, page, *args, **kwargs): + """ + Default context attributes: + * self: section being rendered + * page: current page being rendered + * request: request used to render the current page + + Other context attributes usable in the default template: + * content: **safe string** set as content of the section + """ return { 'self': self, 'page': page, + 'request': request, } def render(self, request, page, *args, **kwargs): """ Render the section. Page is the current publication being rendered. + + Rendering is similar to pages, using 'template' attribute set + by default to the app_label/sections/model_name_snake_case.html + + If the default template is not found, use SectionItem's one, + that can have a context attribute 'content' that is used to render + content. """ return render_to_string( - request, self.template, + self.template, self.get_context(request, *args, page=page, **kwargs) ) @@ -520,22 +633,78 @@ class SectionText(SectionItem): FieldPanel('body'), ] + def get_context(self, request, page, *args, **kwargs): + from wagtail.wagtailcore.rich_text import expand_db_html + context = super().get_context(request, page, *args, **kwargs) + context['content'] = expand_db_html(self.body) + return context @register_snippet class SectionImage(SectionItem): + class ResizeMode(IntEnum): + max = 0x00 + min = 0x01 + crop = 0x02 + image = models.ForeignKey( 'wagtailimages.Image', verbose_name = _('image'), related_name='+', ) + width = models.SmallIntegerField( + _('width'), + blank=True, null=True, + help_text=_('if set and > 0, set a maximum width for the image'), + ) + height = models.SmallIntegerField( + _('height'), + blank=True, null=True, + help_text=_('if set 0 and > 0, set a maximum height for the image'), + ) + resize_mode = models.SmallIntegerField( + verbose_name = _('resize mode'), + choices = [ (int(y), _(x)) for x,y in ResizeMode.__members__.items() ], + default = int(ResizeMode.max), + help_text=_('if the image is resized, set the resizing mode'), + ) + panels = SectionItem.panels + [ ImageChooserPanel('image'), + MultiFieldPanel([ + FieldPanel('width'), + FieldPanel('height'), + FieldPanel('resize_mode'), + ], heading=_('Resizing')) ] + def get_context(self, request, page, *args, **kwargs): + context = super().get_context(request, page, *args, **kwargs) + + image = self.image + if self.width or self.height: + filter_spec = \ + 'width-{}'.format(self.width) if not self.height else \ + 'height-{}'.format(self.height) if not self.width else \ + '{}-{}x{}'.format( + self.get_resize_mode_display(), + self.width, self.height + ) + filter_spec = (image.id, filter_spec) + url = reverse( + 'wagtailimages_serve', + args=(generate_signature(*filter_spec), *filter_spec) + ) + else: + url = image.file.url + + context['content'] = ''.format(url) + return context + @register_snippet -class SectionLink(RelatedLinkBase,SectionItem): +class SectionLink(RelatedLinkBase, SectionItem): """ + Render a link to a page or a given url. Can either be used standalone or in a SectionLinkList """ parent = ParentalKey('SectionLinkList', related_name='links', @@ -544,21 +713,83 @@ class SectionLink(RelatedLinkBase,SectionItem): @register_snippet -class SectionLinkList(SectionItem,ClusterableModel): +class SectionLinkList(SectionItem, ClusterableModel): + """ + Render a list of links. If related to the current page, print + the page's links otherwise, the assigned link list. + + Note: assign the link's class to the tag if there is some. + """ panels = SectionItem.panels + [ InlinePanel('links', label=_('links')) ] + def get_context(self, request, page, *args, **kwargs): + context = super().get_context(*args, **kwargs) + links = \ + self.links if not self.is_related else \ + page.related_links if hasattr(page, 'related_links') else \ + None + context['object_list'] = links + return context + @register_snippet -class SectionPublicationList(ListBase,SectionItem): +class SectionList(ListBase, SectionItem): + """ + This one is quite badass, but needed: render a list of pages + using given parameters (cf. ListBase). + + If focus_available, the first article in the list will be the last + article with a focus, and will be rendered in a bigger size. + """ focus_available = models.BooleanField( _('focus available'), default = False, help_text = _('if true, highlight the first focused article found') ) - panels = SectionItem.panels + [ FieldPanel('focus_available') ] +\ - ListBase.panels + count = models.SmallIntegerField( + _('count'), + default = 5, + help_text = _('number of items to display in the list'), + ) + url_text = models.CharField( + _('text of the url'), + max_length=32, + blank = True, null = True, + help_text = _('use this text to display an URL to the complete ' + 'list. If empty, does not print an address'), + ) + panels = SectionItem.panels + [ + MultiFieldPanel([ + FieldPanel('focus_available'), + FieldPanel('count'), + FieldPanel('url_text'), + ], heading=_('Rendering')), + ] + ListBase.panels + def get_context(self, request, page, *args, **kwargs): + from aircox.cms.models import Publication + context = super().get_context(request, page, *args, **kwargs) + + qs = self.get_queryset() + if self.focus_available: + focus = qs.type(Publication).filter(focus = True).first() + if focus: + focus.css_class = \ + focus.css_class + ' focus' if focus.css_class else 'focus' + qs = qs.exclude(focus.page) + else: + focus = None + pages = qs[:self.count - (focus != None)] + + context['focus'] = focus + context['object_list'] = pages + if self.url_text: + context['url'] = self.to_url( + list_page = self.is_related and page + ) + + return context diff --git a/cms/templates/cms/base_site.html b/cms/templates/cms/base_site.html index 754169b..636fe92 100644 --- a/cms/templates/cms/base_site.html +++ b/cms/templates/cms/base_site.html @@ -1,7 +1,11 @@ {% load staticfiles %} {% load i18n %} + {% load wagtailimages_tags %} {% load wagtailsettings_tags %} + +{% load aircox_cms %} + {% get_settings %} @@ -26,7 +30,7 @@
diff --git a/cms/templates/cms/sections/section_item.html b/cms/templates/cms/sections/section_item.html new file mode 100644 index 0000000..6edb4cd --- /dev/null +++ b/cms/templates/cms/sections/section_item.html @@ -0,0 +1,8 @@ +
+ {% block title %} + {% if self.show_title %}

{{ self.title }}

{% endif %} + {% endblock %} + {% block content %}{{ content|safe }}{% endblock %} +
+ + diff --git a/cms/templates/cms/sections/section_link.html b/cms/templates/cms/sections/section_link.html new file mode 100644 index 0000000..035fb30 --- /dev/null +++ b/cms/templates/cms/sections/section_link.html @@ -0,0 +1,13 @@ +{% extends "cms/sections/section_item.html" %} +{% load wagtailimages_tags %} + +{% block content %} +{% with link=self.as_dict %} +
+ {% if link.icon %}{% image link.icon fill-32x32 class="icon" %}{% endif %} + {{ link.text }} + +{% endwith %} +{% endblock %} + + diff --git a/cms/templates/cms/sections/section_link_list.html b/cms/templates/cms/sections/section_link_list.html new file mode 100644 index 0000000..6c347a5 --- /dev/null +++ b/cms/templates/cms/sections/section_link_list.html @@ -0,0 +1,16 @@ +{% extends "cms/sections/section_item.html" %} +{% load wagtailimages_tags %} + +{% block content %} +{% for item in object_list %} + {% with link=item.as_dict %} + + {% if link.icon %}{% image link.icon fill-32x32 class="icon" %}{% endif %} + {{ link.text }} + + {% endwith %} +{% endfor %} +{% endblock %} + + diff --git a/cms/templates/cms/sections/section_list.html b/cms/templates/cms/sections/section_list.html new file mode 100644 index 0000000..398ff27 --- /dev/null +++ b/cms/templates/cms/sections/section_list.html @@ -0,0 +1,17 @@ +{% extends "cms/sections/section_item.html" %} + +{% block content %} +{% if focus %} +{% with item=focus item_big_cover=True %} +{% include "cms/snippets/list_item.html" %} +{% endwith %} +{% endif %} + +{% include "cms/snippets/list.html" %} + +{% if url %} + +{% endif %} +{% endblock %} + + diff --git a/cms/templates/cms/snippets/date_list.html b/cms/templates/cms/snippets/date_list.html index fc56d66..c9c1a83 100644 --- a/cms/templates/cms/snippets/date_list.html +++ b/cms/templates/cms/snippets/date_list.html @@ -26,7 +26,7 @@ {% if day == nav_dates.date %}class="today"{% endif %}> {# you might like to hide it by default -- this more for sections #}

{{ day|date:'l d F' }}

- {% with object_list=list list_date_format="H:i" %} + {% with object_list=list item_date_format="H:i" %} {% for item in list %} {% include "cms/snippets/list_item.html" %} {% endfor %} diff --git a/cms/templates/cms/snippets/list_item.html b/cms/templates/cms/snippets/list_item.html index 840f9db..1eecbfd 100644 --- a/cms/templates/cms/snippets/list_item.html +++ b/cms/templates/cms/snippets/list_item.html @@ -4,14 +4,19 @@ ListItem instance. Options: * item: item to render. Fields: title, summary, cover, url, date, info, css_class -* list_date_format: format passed to the date filter instead of default one +* item_date_format: format passed to the date filter instead of default one +* item_big_cover: cover should is big instead of thumbnail (width: 600) {% endcomment %} {% load wagtailimages_tags %}
- {% image item.cover fill-64x64 class="cover item_cover" %} + {% if item_big_cover %} + {% image item.cover max-600 class="cover item_cover" %} + {% else %} + {% image item.cover fill-64x64 class="cover item_cover" %} + {% endif %}

{{ item.title }}

{% if item.summary %}
{{ item.summary }}
{% endif %} {% if not item.show_in_menus and item.date %} diff --git a/cms/templatetags/aircox_cms.py b/cms/templatetags/aircox_cms.py index 6d4851d..6173550 100644 --- a/cms/templatetags/aircox_cms.py +++ b/cms/templatetags/aircox_cms.py @@ -1,7 +1,7 @@ from django import template -from django.db.models import Q +from django.utils.safestring import mark_safe -import aircox.cms.sections as sections +from aircox.cms.sections import Section register = template.Library() @@ -13,21 +13,15 @@ def around(page_num, n): return range(page_num-n, page_num+n+1) @register.simple_tag(takes_context=True) -def render_section(position = None, section = None, context = None): +def render_sections(context, position = None): """ - If section is not given, render all sections at the given - position (filter out base on page models' too, cf. Section.model). + Render all sections at the given position (filter out base on page + models' too, cf. Section.model). """ request = context.get('request') page = context.get('page') - - if section: - return section.render(request, page=page) - - sections = Section.objects \ - .filter( Q(model__isnull = True) | Q(model = type(page)) ) \ - .filter(position = position) - return '\n'.join( - section.render(request, page=page) for section in sections - ) + return mark_safe(''.join( + section.render(request, page=page) + for section in Section.get_sections_at(position, page) + )) diff --git a/controllers/models.py b/controllers/models.py index 61e568e..1cdde1d 100644 --- a/controllers/models.py +++ b/controllers/models.py @@ -14,7 +14,7 @@ sources that are used to generate the audio stream: import os import datetime import logging -from enum import Enum, IntEnum +from enum import IntEnum from django.db import models from django.contrib.contenttypes.fields import GenericForeignKey diff --git a/notes.md b/notes.md index ceed4b3..6ded820 100644 --- a/notes.md +++ b/notes.md @@ -1,3 +1,16 @@ +This file is used as a reminder, can be used as crappy documentation too. + +# conventions +## coding style +* name of classes relative to a class: + - metaclass: `class_name + 'Meta'` + - base classes: `class_name + 'Base'` + +## aircox.cms +* icons: cropped to 32x32 +* cover in list items: cropped 64x64 + + # TODO: - general: diff --git a/programs/models.py b/programs/models.py index 8a0dc8e..461c796 100755 --- a/programs/models.py +++ b/programs/models.py @@ -2,7 +2,7 @@ import datetime import os import shutil import logging -from enum import Enum, IntEnum +from enum import IntEnum from django.db import models from django.template.defaultfilters import slugify