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..608a51439 --- /dev/null +++ b/docs/advanced_topics/jinja2.rst @@ -0,0 +1,101 @@ +.. _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', + ], + }, + } + ] + +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 +===================== + +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 228bbe67c..404cb37b1 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 ================ 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 new file mode 100644 index 000000000..5bcc1a05a --- /dev/null +++ b/wagtail/wagtailadmin/templatetags/jinja2.py @@ -0,0 +1,19 @@ +from __future__ import absolute_import + +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 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/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) diff --git a/wagtail/wagtailcore/templatetags/jinja2.py b/wagtail/wagtailcore/templatetags/jinja2.py new file mode 100644 index 000000000..7c10132f0 --- /dev/null +++ b/wagtail/wagtailcore/templatetags/jinja2.py @@ -0,0 +1,24 @@ +from __future__ import absolute_import + +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/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/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) diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index 6041f1585..f1670dfc9 100644 --- a/wagtail/wagtailimages/models.py +++ b/wagtail/wagtailimages/models.py @@ -454,6 +454,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..9a132e88a --- /dev/null +++ b/wagtail/wagtailimages/templatetags/jinja2.py @@ -0,0 +1,41 @@ +from __future__ import absolute_import + +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 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)