implemented filter chaining

pull/2006/merge
shredding 2015-11-18 18:17:36 +01:00 zatwierdzone przez Matt Westcott
rodzic 7fee62f8eb
commit c6b2cef575
6 zmienionych plików z 115 dodań i 20 usunięć

Wyświetl plik

@ -25,6 +25,7 @@ Changelog
* Added a system check to warn developers who use a custom Wagtail build but forgot to build the admin css
* Added success message after updating image from the image upload view (Christian Peters)
* Added a `request.is_preview` variable for templates to distinguish between previewing and live (Denis Voskvitsov)
* Added support for chaining multiple image operations on the `{% image %}` tag (Christian Peters)
* New translations for Arabic and Latvian
* 'Pages' link on site stats dashboard now links to the site homepage when only one site exists, rather than the root level
* Fix: Images and page revisions created by a user are no longer deleted when the user is deleted (Rich Atkinson)

Wyświetl plik

@ -71,6 +71,7 @@ Minor features
* Added success message after updating image from the image upload view (Christian Peters)
* Added a ``request.is_preview`` variable for templates to distinguish between previewing and live (Denis Voskvitsov)
* 'Pages' link on site stats dashboard now links to the site homepage when only one site exists, rather than the root level
* Added support for chaining multiple image operations on the ``{% image %}`` tag (Christian Peters)
* New translations for Arabic and Latvian

Wyświetl plik

@ -262,7 +262,7 @@ class AbstractImage(models.Model, TagSearchable):
'gif': '.gif',
}
output_extension = filter.spec + FORMAT_EXTENSIONS[output_format]
output_extension = filter.spec.replace('|', '.') + FORMAT_EXTENSIONS[output_format]
if cache_key:
output_extension = cache_key + '.' + output_extension
@ -360,10 +360,12 @@ def get_image_model():
class Filter(models.Model):
"""
Represents an operation that can be applied to an Image to produce a rendition
Represents one or more operations that can be applied to an Image to produce a rendition
appropriate for final display on the website. Usually this would be a resize operation,
but could potentially involve colour processing, etc.
"""
# The spec pattern is operation1-var1-var2|operation2-var1
spec = models.CharField(max_length=255, db_index=True, unique=True)
@cached_property
@ -373,7 +375,7 @@ class Filter(models.Model):
# Build list of operation objects
operations = []
for op_spec in self.spec.split():
for op_spec in self.spec.split('|'):
op_spec_parts = op_spec.split('-')
if op_spec_parts[0] not in self._registered_operations:
@ -381,7 +383,6 @@ class Filter(models.Model):
op_class = self._registered_operations[op_spec_parts[0]]
operations.append(op_class(*op_spec_parts))
return operations
def run(self, image, output):

Wyświetl plik

@ -1,3 +1,5 @@
import re
from django import template
from django.utils.functional import cached_property
@ -5,33 +7,60 @@ from wagtail.wagtailimages.models import Filter
from wagtail.wagtailimages.shortcuts import get_rendition_or_not_found
register = template.Library()
allowed_filter_pattern = re.compile("^[A-Za-z0-9_\-\.]+$")
@register.tag(name="image")
def image(parser, token):
bits = token.split_contents()[1:]
image_expr = parser.compile_filter(bits[0])
filter_spec = bits[1]
bits = bits[2:]
bits = bits[1:]
if len(bits) == 2 and bits[0] == 'as':
# token is of the form {% image self.photo max-320x200 as img %}
return ImageNode(image_expr, filter_spec, output_var_name=bits[1])
else:
# token is of the form {% image self.photo max-320x200 %} - all additional tokens
# should be kwargs, which become attributes
attrs = {}
for bit in bits:
filter_specs = []
attrs = {}
output_var_name = None
as_context = False # if True, the next bit to be read is the output variable name
is_valid = True
for bit in bits:
if bit == 'as':
# token is of the form {% image self.photo max-320x200 as img %}
as_context = True
elif as_context:
if output_var_name is None:
output_var_name = bit
else:
# more than one item exists after 'as' - reject as invalid
is_valid = False
else:
try:
name, value = bit.split('=')
attrs[name] = parser.compile_filter(value) # setup to resolve context variables as value
except ValueError:
raise template.TemplateSyntaxError(
"""'image' tag should be of the form {% image self.photo max-320x200
[ custom-attr=\"value\" ... ] %} or {% image self.photo max-320x200 as img %}"""
)
attrs[name] = parser.compile_filter(value) # setup to resolve context variables as value
if allowed_filter_pattern.match(bit):
filter_specs.append(bit)
else:
raise template.TemplateSyntaxError(
"filter specs in 'image' tag may only contain A-Z, a-z, 0-9, dots, hyphens and underscores. "
"(given filter: {})".format(bit)
)
return ImageNode(image_expr, filter_spec, attrs=attrs)
if as_context and output_var_name is None:
# context was introduced but no variable given ...
is_valid = False
if output_var_name and attrs:
# attributes are not valid when using the 'as img' form of the tag
is_valid = False
if is_valid:
return ImageNode(image_expr, '|'.join(filter_specs), attrs=attrs, output_var_name=output_var_name)
else:
raise template.TemplateSyntaxError(
"'image' tag should be of the form {% image self.photo max-320x200 [ custom-attr=\"value\" ... ] %} "
"or {% image self.photo max-320x200 as img %}"
)
class ImageNode(template.Node):

Wyświetl plik

@ -1,8 +1,13 @@
import unittest
from mock import Mock
from django.utils.six import BytesIO
from wagtail.wagtailcore import hooks
from wagtail.wagtailimages import image_operations
from wagtail.wagtailimages.exceptions import InvalidFilterSpecError
from wagtail.wagtailimages.models import Image, Filter
from wagtail.wagtailimages.tests.utils import get_test_image_file
class WillowOperationRecorder(object):
@ -378,3 +383,28 @@ class TestCacheKey(unittest.TestCase):
cache_key = fil.get_cache_key(image)
self.assertEqual(cache_key, '0bbe3b2f')
class TestFilter(unittest.TestCase):
operation_instance = Mock()
def test_runs_operations(self):
self.operation_instance.run = Mock()
fil = Filter(spec='operation1|operation2')
image = Image.objects.create(
title="Test image",
file=get_test_image_file(),
)
fil.run(image, BytesIO())
self.assertEqual(self.operation_instance.run.call_count, 2)
@hooks.register('register_image_operations')
def register_image_operations():
return [
('operation1', Mock(return_value=TestFilter.operation_instance)),
('operation2', Mock(return_value=TestFilter.operation_instance))
]

Wyświetl plik

@ -90,6 +90,27 @@ class TestImageTag(TestCase):
self.assertTrue('width="400"' in result)
self.assertTrue('height="300"' in result)
def test_image_tag_with_chained_filters(self):
result = self.render_image_tag(self.image, 'fill-200x200 height-150')
self.assertTrue('width="150"' in result)
self.assertTrue('height="150"' in result)
def test_filter_specs_must_match_allowed_pattern(self):
with self.assertRaises(template.TemplateSyntaxError):
self.render_image_tag(self.image, 'fill-200x200|height-150')
with self.assertRaises(template.TemplateSyntaxError):
self.render_image_tag(self.image, 'fill-800x600 alt"test"')
def test_context_may_only_contain_one_argument(self):
with self.assertRaises(template.TemplateSyntaxError):
temp = template.Template(
'{% load wagtailimages_tags %}{% image image_obj fill-200x200'
' as test_img this_one_should_not_be_there %}<img {{ test_img.attrs }} />'
)
context = template.Context({'image_obj': self.image})
temp.render(context)
class TestMissingImage(TestCase):
"""
@ -393,6 +414,18 @@ class TestRenditionFilenames(TestCase):
self.assertEqual(rendition.file.name, 'images/test_rf3.15ee4958.fill-100x100.png')
def test_filter_with_pipe_gets_dotted(self):
image = Image.objects.create(
title="Test image",
file=get_test_image_file(filename='test_rf4.png'),
)
image.set_focal_point(Rect(100, 100, 200, 200))
image.save()
rendition = image.get_rendition('fill-200x200|height-150')
self.assertEqual(rendition.file.name, 'images/test_rf4.15ee4958.fill-200x200.height-150.png')
class TestDifferentUpload(TestCase):
def test_upload_path(self):