From de88f90b36ca6f8fb0a3ced4589ca87902123144 Mon Sep 17 00:00:00 2001 From: Tim Heap Date: Wed, 30 Sep 2015 18:44:35 +1000 Subject: [PATCH 1/7] Add rudimentary Jinja2 template tag support The following tags and filters are supported: * `wagtailcore`: * `pageurl()` * `slugurl()` * `wagtail_version()` * `|pageurl` * `wagtailimages`: * `image()` Django template tags have been translated to Jinja functions, rather than custom tags. Functions are easier to use compared to template tags, and can be composed and combined for greater flexibility. The template tag libraries have been grouped as Jinja Extensions, which are loadable via the `extensions` option. An example Django Jinja2 configuration is: ```python TEMPLATES = [ # ... { 'BACKEND': 'django.template.backends.jinja2.Jinja2', 'APP_DIRS': True, 'OPTIONS': { 'extensions': [ 'wagtail.wagtailcore.templatetags.jinja2.core', 'wagtail.wagtailimages.templatetags.jinja2.images', ], }, }, ] ``` --- wagtail/wagtailcore/templatetags/jinja2.py | 22 +++++++++++ wagtail/wagtailimages/models.py | 3 ++ wagtail/wagtailimages/templatetags/jinja2.py | 39 ++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 wagtail/wagtailcore/templatetags/jinja2.py create mode 100644 wagtail/wagtailimages/templatetags/jinja2.py diff --git a/wagtail/wagtailcore/templatetags/jinja2.py b/wagtail/wagtailcore/templatetags/jinja2.py new file mode 100644 index 000000000..8b1bcb98c --- /dev/null +++ b/wagtail/wagtailcore/templatetags/jinja2.py @@ -0,0 +1,22 @@ +import jinja2 +from jinja2.ext import Extension + +from .wagtailcore_tags import pageurl, richtext, slugurl, wagtail_version + + +class WagtailCoreExtension(Extension): + def __init__(self, environment): + super(WagtailCoreExtension, self).__init__(environment) + + self.environment.globals.update({ + 'pageurl': jinja2.contextfunction(pageurl), + 'slugurl': jinja2.contextfunction(slugurl), + 'wagtail_version': wagtail_version, + }) + self.environment.filters.update({ + 'richtext': richtext, + }) + + +# Nicer import names +core = WagtailCoreExtension diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index 74af76aed..7c3f8b8eb 100644 --- a/wagtail/wagtailimages/models.py +++ b/wagtail/wagtailimages/models.py @@ -448,6 +448,9 @@ class AbstractRendition(models.Model): else: return mark_safe('' % self.attrs) + def __html__(self): + return self.img_tag() + class Meta: abstract = True diff --git a/wagtail/wagtailimages/templatetags/jinja2.py b/wagtail/wagtailimages/templatetags/jinja2.py new file mode 100644 index 000000000..5025c1646 --- /dev/null +++ b/wagtail/wagtailimages/templatetags/jinja2.py @@ -0,0 +1,39 @@ +from jinja2.ext import Extension + +from wagtail.wagtailimages.models import SourceImageIOError + + +def image(image, filterspec, **attrs): + if not image: + return '' + + try: + rendition = image.get_rendition(filterspec) + except SourceImageIOError: + # It's fairly routine for people to pull down remote databases to their + # local dev versions without retrieving the corresponding image files. + # In such a case, we would get a SourceImageIOError at the point where we try to + # create the resized version of a non-existent image. Since this is a + # bit catastrophic for a missing image, we'll substitute a dummy + # Rendition object so that we just output a broken link instead. + Rendition = image.renditions.model # pick up any custom Image / Rendition classes that may be in use + rendition = Rendition(image=image, width=0, height=0) + rendition.file.name = 'not-found' + + if attrs: + return rendition.img_tag(attrs) + else: + return rendition + + +class WagtailImagesExtension(Extension): + def __init__(self, environment): + super(WagtailImagesExtension, self).__init__(environment) + + self.environment.globals.update({ + 'image': image, + }) + + +# Nicer import names +images = WagtailImagesExtension From 02e4288b15f059881aeea1cbd942670f34f79e8b Mon Sep 17 00:00:00 2001 From: Tim Heap Date: Thu, 1 Oct 2015 14:59:13 +1000 Subject: [PATCH 2/7] Add `wagtailuserbar()` Jinja function --- wagtail/wagtailadmin/templatetags/jinja2.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 wagtail/wagtailadmin/templatetags/jinja2.py diff --git a/wagtail/wagtailadmin/templatetags/jinja2.py b/wagtail/wagtailadmin/templatetags/jinja2.py new file mode 100644 index 000000000..0b24069b0 --- /dev/null +++ b/wagtail/wagtailadmin/templatetags/jinja2.py @@ -0,0 +1,17 @@ +import jinja2 +from jinja2.ext import Extension + +from .wagtailuserbar import wagtailuserbar + + +class WagtailUserbarExtension(Extension): + def __init__(self, environment): + super(WagtailUserbarExtension, self).__init__(environment) + + self.environment.globals.update({ + 'wagtailuserbar': jinja2.contextfunction(wagtailuserbar), + }) + + +# Nicer import names +userbar = WagtailUserbarExtension From e225481f2fa2df153d2a9fb2f120051979bc29fe Mon Sep 17 00:00:00 2001 From: Tim Heap Date: Thu, 1 Oct 2015 15:52:04 +1000 Subject: [PATCH 3/7] Add documentation for using Jinja2 --- docs/advanced_topics/index.rst | 1 + docs/advanced_topics/jinja2.rst | 97 +++++++++++++++++++++++++++++++ docs/topics/writing_templates.rst | 12 +++- 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 docs/advanced_topics/jinja2.rst diff --git a/docs/advanced_topics/index.rst b/docs/advanced_topics/index.rst index 50909406b..ce28b9e41 100644 --- a/docs/advanced_topics/index.rst +++ b/docs/advanced_topics/index.rst @@ -12,3 +12,4 @@ Advanced topics privacy customisation/index third_party_tutorials + jinja2 diff --git a/docs/advanced_topics/jinja2.rst b/docs/advanced_topics/jinja2.rst new file mode 100644 index 000000000..9913a592b --- /dev/null +++ b/docs/advanced_topics/jinja2.rst @@ -0,0 +1,97 @@ +.. _jinja2: + +======================= +Jinja2 template support +======================= + +Wagtail supports Jinja2 templating for all front end features. More information on each of the template tags below can be found in the :ref:`writing_templates` documentation. + +Configuring Django +================== + +Django needs to be configured to support Jinja2 templates. As the Wagtail admin is written using regular Django templates, Django has to be configured to use both templating engines. Wagtail supports the Jinja2 backend that ships with Django 1.8 and above. Add the following configuration to the ``TEMPLATES`` setting for your app: + +.. code-block:: python + + TEMPLATES = [ + # ... + { + 'BACKEND': 'django.template.backends.jinja2.Jinja2', + 'APP_DIRS': True, + 'OPTIONS': { + 'extensions': [ + 'wagtail.wagtailcore.templatetags.jinja2.core', + 'wagtail.wagtailadmin.templatetags.jinja2.userbar', + 'wagtail.wagtailimages.templatetags.jinja2.images', + ], + }, + } + ] + +``self`` in templates +===================== + +In Django templates, ``self`` is used to refer to the current page, stream block, or field panel. In Jinja, ``self`` is reserved for internal use. When writing Jinja templates, use ``page`` to refer to pages, ``value`` for stream blocks, and ``field_panel`` for field panels. + +Template functions & filters +============================ + +``pageurl()`` +~~~~~~~~~~~~~ + +Generate a URL for a Page instance: + +.. code-block:: html+jinja + + More information + +See :ref:`pageurl_tag` for more information + +``slugurl()`` +~~~~~~~~~~~~~ + +Generate a URL for a Page with a slug: + +.. code-block:: html+jinja + + About us + +See :ref:`slugurl_tag` for more information + +``image()`` +~~~~~~~~~~~ + +Resize an image, and print an ```` tag: + +.. code-block:: html+jinja + + {# Print an image tag #} + {{ image(page.header_image, "fill-1024x200", class="header-image") }} + + {# Resize an image #} + {% set background=image(page.background_image, "max-1024x1024") %} +
+ +See :ref:`image_tag` for more information + +``|richtext`` +~~~~~~~~~~~~~ + +Transform Wagtail's internal HTML representation, expanding internal references to pages and images. + +.. code-block:: html+jinja + + {{ page.body|richtext }} + +See :ref:`rich-text-filter` for more information + +``wagtailuserbar()`` +~~~~~~~~~~~~~~~~~~~~ + +Output the Wagtail contextual flyout menu for editing pages from the front end + +.. code-block:: html+jinja + + {{ wagtailuserbar() }} + +See :ref:`wagtailuserbar_tag` for more information diff --git a/docs/topics/writing_templates.rst b/docs/topics/writing_templates.rst index d8c85f720..8f6f0aa28 100644 --- a/docs/topics/writing_templates.rst +++ b/docs/topics/writing_templates.rst @@ -1,3 +1,5 @@ +.. _writing_templates: + ================= Writing templates ================= @@ -80,7 +82,6 @@ In addition to Django's standard tags and filters, Wagtail provides some of its Images (tag) ~~~~~~~~~~~~ - The ``image`` tag inserts an XHTML-compatible ``img`` element into the page, setting its ``src``, ``width``, ``height`` and ``alt``. See also :ref:`image_tag_alt`. The syntax for the tag is thus:: @@ -147,6 +148,8 @@ Wagtail embeds and images are included at their full width, which may overflow t Internal links (tag) ~~~~~~~~~~~~~~~~~~~~ +.. _pageurl_tag: + ``pageurl`` ----------- @@ -158,8 +161,10 @@ Takes a Page object and returns a relative URL (``/foo/bar/``) if within the sam ... -slugurl --------- +.. _slugurl_tag: + +``slugurl`` +------------ Takes any ``slug`` as defined in a page's "Promote" tab and returns the URL for the matching Page. Like ``pageurl``, will try to provide a relative link if possible, but will default to an absolute link if on a different site. This is most useful when creating shared page furniture e.g top level navigation or site-wide links. @@ -186,6 +191,7 @@ Used to load anything from your static files directory. Use of this tag avoids r Notice that the full path name is not required and the path snippet you enter only need begin with the parent app's directory name. +.. _wagtailuserbar_tag: Wagtail User Bar ================ From cf7aaae09cbea4f6cb56fe4ba45a8af7f8061036 Mon Sep 17 00:00:00 2001 From: Tim Heap Date: Thu, 1 Oct 2015 17:33:22 +1000 Subject: [PATCH 4/7] Add tests for Jinja template tags --- tox.ini | 1 + wagtail/tests/settings.py | 11 ++++ wagtail/wagtailadmin/templatetags/jinja2.py | 2 + wagtail/wagtailadmin/tests/test_jinja2.py | 52 +++++++++++++++ wagtail/wagtailcore/templatetags/jinja2.py | 2 + wagtail/wagtailcore/tests/test_jinja2.py | 55 ++++++++++++++++ wagtail/wagtailimages/templatetags/jinja2.py | 2 + wagtail/wagtailimages/tests/test_jinja2.py | 66 ++++++++++++++++++++ 8 files changed, 191 insertions(+) create mode 100644 wagtail/wagtailadmin/tests/test_jinja2.py create mode 100644 wagtail/wagtailcore/tests/test_jinja2.py create mode 100644 wagtail/wagtailimages/tests/test_jinja2.py diff --git a/tox.ini b/tox.ini index eb5f94267..a597328a8 100644 --- a/tox.ini +++ b/tox.ini @@ -35,6 +35,7 @@ deps = pytz==2014.7 Embedly Willow==0.2 + jinja2==2.8 coverage dj17: Django>=1.7.1,<1.8 diff --git a/wagtail/tests/settings.py b/wagtail/tests/settings.py index 1423b2039..d547d2411 100644 --- a/wagtail/tests/settings.py +++ b/wagtail/tests/settings.py @@ -56,6 +56,17 @@ if django.VERSION >= (1, 8): ], }, }, + { + 'BACKEND': 'django.template.backends.jinja2.Jinja2', + 'APP_DIRS': True, + 'OPTIONS': { + 'extensions': [ + 'wagtail.wagtailcore.templatetags.jinja2.core', + 'wagtail.wagtailadmin.templatetags.jinja2.userbar', + 'wagtail.wagtailimages.templatetags.jinja2.images', + ], + }, + }, ] else: TEMPLATE_CONTEXT_PROCESSORS = global_settings.TEMPLATE_CONTEXT_PROCESSORS + ( diff --git a/wagtail/wagtailadmin/templatetags/jinja2.py b/wagtail/wagtailadmin/templatetags/jinja2.py index 0b24069b0..5bcc1a05a 100644 --- a/wagtail/wagtailadmin/templatetags/jinja2.py +++ b/wagtail/wagtailadmin/templatetags/jinja2.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + import jinja2 from jinja2.ext import Extension diff --git a/wagtail/wagtailadmin/tests/test_jinja2.py b/wagtail/wagtailadmin/tests/test_jinja2.py new file mode 100644 index 000000000..b53926159 --- /dev/null +++ b/wagtail/wagtailadmin/tests/test_jinja2.py @@ -0,0 +1,52 @@ +from __future__ import absolute_import, unicode_literals + + +import unittest + +import django +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.test import TestCase + +from wagtail.wagtailcore.models import Page, PAGE_TEMPLATE_VAR, Site + + +@unittest.skipIf(django.VERSION < (1, 8), 'Multiple engines only supported in Django>=1.8') +class TestCoreJinja(TestCase): + + def setUp(self): + # This does not exist on Django<1.8 + from django.template import engines + self.engine = engines['jinja2'] + + self.user = get_user_model().objects.create_superuser(username='test', email='test@email.com', password='password') + self.homepage = Page.objects.get(id=2) + + def render(self, string, context=None, request_context=True): + if context is None: + context = {} + + template = self.engine.from_string(string) + return template.render(context) + + def dummy_request(self, user=None): + site = Site.objects.get(is_default_site=True) + + request = self.client.get('/') + request.site = site + request.user = user or AnonymousUser() + return request + + def test_userbar(self): + content = self.render('{{ wagtailuserbar() }}', { + PAGE_TEMPLATE_VAR: self.homepage, + 'request': self.dummy_request(self.user)}) + self.assertIn("", content) + + def test_userbar_anonymous_user(self): + content = self.render('{{ wagtailuserbar() }}', { + PAGE_TEMPLATE_VAR: self.homepage, + 'request': self.dummy_request()}) + + # Make sure nothing was rendered + self.assertEqual(content, '') diff --git a/wagtail/wagtailcore/templatetags/jinja2.py b/wagtail/wagtailcore/templatetags/jinja2.py index 8b1bcb98c..7c10132f0 100644 --- a/wagtail/wagtailcore/templatetags/jinja2.py +++ b/wagtail/wagtailcore/templatetags/jinja2.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + import jinja2 from jinja2.ext import Extension diff --git a/wagtail/wagtailcore/tests/test_jinja2.py b/wagtail/wagtailcore/tests/test_jinja2.py new file mode 100644 index 000000000..50cf43d19 --- /dev/null +++ b/wagtail/wagtailcore/tests/test_jinja2.py @@ -0,0 +1,55 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +import django +from django.test import TestCase + +from wagtail.wagtailcore import __version__ +from wagtail.wagtailcore.models import Page, Site + + +@unittest.skipIf(django.VERSION < (1, 8), 'Multiple engines only supported in Django>=1.8') +class TestCoreJinja(TestCase): + + def setUp(self): + # This does not exist on Django<1.8 + from django.template import engines + self.engine = engines['jinja2'] + + def render(self, string, context=None, request_context=True): + if context is None: + context = {} + + # Add a request to the template, to simulate a RequestContext + if request_context: + site = Site.objects.get(is_default_site=True) + request = self.client.get('/test/', HTTP_HOST=site.hostname) + request.site = site + context['request'] = request + + template = self.engine.from_string(string) + return template.render(context) + + def test_richtext(self): + richtext = '

Merry Christmas!

' + self.assertEqual( + self.render('{{ text|richtext }}', {'text': richtext}), + '

Merry Christmas!

') + + def test_pageurl(self): + page = Page.objects.get(pk=2) + self.assertEqual( + self.render('{{ pageurl(page) }}', {'page': page}), + page.url) + + def test_slugurl(self): + page = Page.objects.get(pk=2) + self.assertEqual( + self.render('{{ slugurl(page.slug) }}', {'page': page}), + page.url) + + def test_wagtail_version(self): + self.assertEqual( + self.render('{{ wagtail_version() }}'), + __version__) diff --git a/wagtail/wagtailimages/templatetags/jinja2.py b/wagtail/wagtailimages/templatetags/jinja2.py index 5025c1646..9a132e88a 100644 --- a/wagtail/wagtailimages/templatetags/jinja2.py +++ b/wagtail/wagtailimages/templatetags/jinja2.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + from jinja2.ext import Extension from wagtail.wagtailimages.models import SourceImageIOError diff --git a/wagtail/wagtailimages/tests/test_jinja2.py b/wagtail/wagtailimages/tests/test_jinja2.py new file mode 100644 index 000000000..97badb046 --- /dev/null +++ b/wagtail/wagtailimages/tests/test_jinja2.py @@ -0,0 +1,66 @@ +from __future__ import absolute_import, unicode_literals + +import os +import unittest + +import django +from django.conf import settings +from django.test import TestCase + +from wagtail.wagtailcore.models import Site + +from .utils import get_test_image_file, Image + + +@unittest.skipIf(django.VERSION < (1, 8), 'Multiple engines only supported in Django>=1.8') +class TestImagesJinja(TestCase): + + def setUp(self): + # This does not exist on Django<1.8 + from django.template import engines + self.engine = engines['jinja2'] + + self.image = Image.objects.create( + title="Test image", + file=get_test_image_file(), + ) + + def render(self, string, context=None, request_context=True): + if context is None: + context = {} + + # Add a request to the template, to simulate a RequestContext + if request_context: + site = Site.objects.get(is_default_site=True) + request = self.client.get('/test/', HTTP_HOST=site.hostname) + request.site = site + context['request'] = request + + template = self.engine.from_string(string) + return template.render(context) + + def get_image_filename(self, image, filterspec): + """ + Get the generated filename for a resized image + """ + name, ext = os.path.splitext(os.path.basename(image.file.name)) + return '{}images/{}.{}{}'.format( + settings.MEDIA_URL, name, filterspec, ext) + + def test_image(self): + self.assertHTMLEqual( + self.render('{{ image(myimage, "width-200") }}', {'myimage': self.image}), + 'Test image'.format( + self.get_image_filename(self.image, "width-200"))) + + def test_image_attributes(self): + self.assertHTMLEqual( + self.render('{{ image(myimage, "width-200", class="test") }}', {'myimage': self.image}), + 'Test image'.format( + self.get_image_filename(self.image, "width-200"))) + + def test_image_assignment(self): + template = ('{% set background=image(myimage, "width-200") %}' + 'width: {{ background.width }}, url: {{ background.url }}') + output = ('width: 200, url: ' + self.get_image_filename(self.image, "width-200")) + self.assertHTMLEqual(self.render(template, {'myimage': self.image}), output) From 21d290af124b3a135c5232da6c39cfe83a1db755 Mon Sep 17 00:00:00 2001 From: Tim Heap Date: Fri, 2 Oct 2015 08:06:28 +1000 Subject: [PATCH 5/7] Add references to Django documentation on configuring Jinja --- docs/advanced_topics/jinja2.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/advanced_topics/jinja2.rst b/docs/advanced_topics/jinja2.rst index 9913a592b..608a51439 100644 --- a/docs/advanced_topics/jinja2.rst +++ b/docs/advanced_topics/jinja2.rst @@ -28,6 +28,10 @@ Django needs to be configured to support Jinja2 templates. As the Wagtail admin } ] +Jinja templates must be placed in a ``jinja2/`` directory in your app. The template for an ``EventPage`` model in an ``events`` app should be created at ``events/jinja2/events/event_page.html``. + +By default, the Jinja environment does not have any Django functions or filters. The Django documentation has more information on `configuring Jinja for Django `_. + ``self`` in templates ===================== From f6fb743a187a7a4d0cd824870df62c024f92836e Mon Sep 17 00:00:00 2001 From: Tim Heap Date: Fri, 2 Oct 2015 08:27:24 +1000 Subject: [PATCH 6/7] Add __html__ method to StreamValues This allows template authors to write `{{ page.stream_field }}` in Jinja2 templates without having to jump through HTML escaping loops. --- wagtail/wagtailcore/blocks/stream_block.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wagtail/wagtailcore/blocks/stream_block.py b/wagtail/wagtailcore/blocks/stream_block.py index a11db90c4..a544a6400 100644 --- a/wagtail/wagtailcore/blocks/stream_block.py +++ b/wagtail/wagtailcore/blocks/stream_block.py @@ -307,5 +307,8 @@ class StreamValue(collections.Sequence): def __repr__(self): return repr(list(self)) + def __html__(self): + return self.__str__() + def __str__(self): return self.stream_block.render(self) From 439e4b9a3d9426aec2fbf8ca709206cd5ee07078 Mon Sep 17 00:00:00 2001 From: Tim Heap Date: Mon, 5 Oct 2015 14:08:37 +1100 Subject: [PATCH 7/7] Add tests for rendered StreamField output --- wagtail/wagtailcore/tests/test_streamfield.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/wagtail/wagtailcore/tests/test_streamfield.py b/wagtail/wagtailcore/tests/test_streamfield.py index c85a2df9d..bfec0be12 100644 --- a/wagtail/wagtailcore/tests/test_streamfield.py +++ b/wagtail/wagtailcore/tests/test_streamfield.py @@ -1,8 +1,13 @@ import json +import unittest +import django from django.apps import apps from django.test import TestCase from django.db import models +from django.template import Template, Context +from django.utils.safestring import SafeText +from django.utils.six import text_type from wagtail.tests.testapp.models import StreamModel from wagtail.wagtailcore import blocks @@ -128,3 +133,56 @@ class TestStreamValueAccess(TestCase): self.assertEqual(len(fetched_body), 1) self.assertIsInstance(fetched_body[0].value, RichText) self.assertEqual(fetched_body[0].value.source, "

hello world

") + + +class TestStreamFieldRenderingBase(TestCase): + def setUp(self): + self.image = Image.objects.create( + title='Test image', + file=get_test_image_file()) + + self.instance = StreamModel.objects.create(body=json.dumps([ + {'type': 'rich_text', 'value': '

Rich text

'}, + {'type': 'image', 'value': self.image.pk}, + {'type': 'text', 'value': 'Hello, World!'}])) + + img_tag = self.image.get_rendition('original').img_tag() + self.expected = ''.join([ + '

Rich text

', + '
{}
'.format(img_tag), + '
Hello, World!
', + ]) + + +class TestStreamFieldRendering(TestStreamFieldRenderingBase): + def test_to_string(self): + rendered = text_type(self.instance.body) + self.assertHTMLEqual(rendered, self.expected) + self.assertIsInstance(rendered, SafeText) + + +class TestStreamFieldDjangoRendering(TestStreamFieldRenderingBase): + def render(self, string, context): + return Template(string).render(Context(context)) + + def test_render(self): + rendered = self.render('{{ instance.body }}', { + 'instance': self.instance}) + self.assertHTMLEqual(rendered, self.expected) + + +@unittest.skipIf(django.VERSION < (1, 8), 'Multiple engines only supported in Django>=1.8') +class TestStreamFieldJinjaRendering(TestStreamFieldRenderingBase): + def setUp(self): + # This does not exist on Django<1.8 + super(TestStreamFieldJinjaRendering, self).setUp() + from django.template import engines + self.engine = engines['jinja2'] + + def render(self, string, context): + return self.engine.from_string(string).render(context) + + def test_render(self): + rendered = self.render('{{ instance.body }}', { + 'instance': self.instance}) + self.assertHTMLEqual(rendered, self.expected)