diff --git a/CHANGES.rst b/CHANGES.rst index 6e04f962..11256979 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,42 @@ Changelog .. towncrier release notes start +6.1.1 (2023-06-22) +------------------ + +Bug fixes: + + +- Return a 400 Bad Request response if the `@@images` view is published without a subpath. @davisagli (#144) + + +Tests + + +- Fix tests to work with various ``beautifulsoup4`` versions. + [maurits] (#867) + + +6.1.0 (2023-05-22) +------------------ + +New features: + + +- Move ``Zope2FileUploadStorable`` code from plone.app.z3cform to here to break a cyclic dependency. + [gforcada] (#3764) + + +6.0.2 (2023-05-08) +------------------ + +Bug fixes: + + +- Fix picture tag when original image is used instead of a scale. + [maurits] (#142) + + 6.0.1 (2023-03-14) ------------------ diff --git a/plone/namedfile/picture.py b/plone/namedfile/picture.py index 6b387861..14ea65bb 100644 --- a/plone/namedfile/picture.py +++ b/plone/namedfile/picture.py @@ -145,5 +145,15 @@ def update_src_scale(self, src, scale): src_scale = "/".join(parts[:-1]) + f"/{field_name}/{scale}" src_scale else: - src_scale = "/".join(parts[:-1]) + f"/{scale}" + # Usually the url has '@@images/fieldname/other_scale', + # and then we replace the other scale. + # But the url may use the original image, e.g. @@images/image. + # Then we want to keep the fieldname and return '.../image/scale'. + try: + full = len(parts) - parts.index("@@images") == 2 + except ValueError: + full = False + if not full: + parts = parts[:-1] + src_scale = "/".join(parts) + f"/{scale}" return src_scale diff --git a/plone/namedfile/scaling.py b/plone/namedfile/scaling.py index c21e8c87..9ec3bd09 100644 --- a/plone/namedfile/scaling.py +++ b/plone/namedfile/scaling.py @@ -21,6 +21,7 @@ from Products.CMFPlone.utils import safe_encode from Products.Five import BrowserView from xml.sax.saxutils import quoteattr +from zExceptions import BadRequest from zExceptions import Unauthorized from ZODB.blob import BlobFile from ZODB.POSException import ConflictError @@ -30,7 +31,7 @@ from zope.deprecation import deprecate from zope.interface import alsoProvides from zope.interface import implementer -from zope.publisher.interfaces import IPublishTraverse +from zope.publisher.interfaces.browser import IBrowserPublisher from zope.publisher.interfaces import NotFound from zope.traversing.interfaces import ITraversable from zope.traversing.interfaces import TraversalError @@ -390,7 +391,7 @@ def __call__( return value, format_, dimensions -@implementer(ITraversable, IPublishTraverse) +@implementer(ITraversable, IBrowserPublisher) class ImageScaling(BrowserView): """view used for generating (and storing) image scales""" @@ -434,6 +435,10 @@ def publishTraverse(self, request, name): return scale_view raise NotFound(self, name, self.request) + def browserDefault(self, request): + # There's nothing in the path after /@@images + raise BadRequest("Missing image scale path") + def traverse(self, name, furtherPath): """used for path traversal, i.e. in zope page templates""" # validate access diff --git a/plone/namedfile/storages.py b/plone/namedfile/storages.py index 5626200f..6bb4c326 100644 --- a/plone/namedfile/storages.py +++ b/plone/namedfile/storages.py @@ -105,3 +105,14 @@ def store(self, pdata, blob): fp = blob.open("w") fp.write(bytes(pdata)) fp.close() + + +@implementer(IStorage) +class Zope2FileUploadStorable: + def store(self, data, blob): + data.seek(0) + with blob.open("w") as fp: + block = data.read(MAXCHUNKSIZE) + while block: + fp.write(block) + block = data.read(MAXCHUNKSIZE) diff --git a/plone/namedfile/tests/test_scaling.py b/plone/namedfile/tests/test_scaling.py index ed4382a1..79347634 100644 --- a/plone/namedfile/tests/test_scaling.py +++ b/plone/namedfile/tests/test_scaling.py @@ -547,9 +547,17 @@ def testGetPictureTagByName(self, mock_uuid_to_object): http://nohost/item/@@images/image-800-....png 800w, http://nohost/item/@@images/image-1000-....png 1000w, http://nohost/item/@@images/image-1200-....png 1200w"/> - + """ - self.assertTrue(_ellipsis_match(expected, tag)) + self.assertTrue(_ellipsis_match(expected, tag.strip())) + + # The exact placement of the img tag attributes can differ, especially + # with different beautifulsoup versions. + # So check here that all attributes are present. + self.assertIn('height="200"', tag) + self.assertIn('loading="lazy"', tag) + self.assertIn('title="foo"', tag) + self.assertIn('width="200"', tag) @patch.object( plone.namedfile.scaling, @@ -580,9 +588,18 @@ def testGetPictureTagWithAltAndTitle(self, mock_uuid_to_object): {base}/@@images/image-800-....png 800w, {base}/@@images/image-1000-....png 1000w, {base}/@@images/image-1200-....png 1200w"/> - Alternative text + """ - self.assertTrue(_ellipsis_match(expected, tag)) + self.assertTrue(_ellipsis_match(expected, tag.strip())) + + # The exact placement of the img tag attributes can differ, especially + # with different beautifulsoup versions. + # So check here that all attributes are present. + self.assertIn('alt="Alternative text"', tag) + self.assertIn('height="200"', tag) + self.assertIn('loading="lazy"', tag) + self.assertIn('title="Custom title"', tag) + self.assertIn('width="200"', tag) @patch.object( plone.namedfile.scaling, @@ -601,8 +618,15 @@ def testGetPictureTagWithoutAnyVariants(self, mock_uuid_to_object): ImageScaling._sizes = patch_Img2PictureTag_allowed_scales() mock_uuid_to_object.return_value = self.item tag = self.scaling.picture("image", picture_variant="medium") - expected = """""" - self.assertTrue(_ellipsis_match(expected, tag)) + expected = """""" + self.assertTrue(_ellipsis_match(expected, tag.strip())) + + # The exact placement of the img tag attributes can differ, especially + # with different beautifulsoup versions. + # So check here that all attributes are present. + self.assertIn('height="200"', tag) + self.assertIn('title="foo"', tag) + self.assertIn('width="200"', tag) def testGetUnknownScale(self): foo = self.scaling.scale("image", scale="foo?") @@ -980,6 +1004,34 @@ def test_title(self): ) +class Img2PictureTagTests(unittest.TestCase): + """Low level tests for Img2PictureTag.""" + + def _makeOne(self): + return plone.namedfile.picture.Img2PictureTag() + + def test_update_src_scale(self): + update_src_scale = self._makeOne().update_src_scale + self.assertEqual( + update_src_scale("foo/fieldname/old", "new"), + "foo/fieldname/new" + ) + self.assertEqual( + update_src_scale("@@images/fieldname/old", "mini"), + "@@images/fieldname/mini" + ) + self.assertEqual( + update_src_scale("@@images/fieldname", "preview"), + "@@images/fieldname/preview" + ) + self.assertEqual( + update_src_scale( + "photo.jpg/@@images/image-1200-4a03b0a8227d28737f5d9e3e481bdbd6.jpeg", + "teaser"), + "photo.jpg/@@images/image/teaser", + ) + + def test_suite(): from unittest import defaultTestLoader diff --git a/plone/namedfile/tests/test_scaling_functional.py b/plone/namedfile/tests/test_scaling_functional.py index b9610ad7..bf8f1fd7 100644 --- a/plone/namedfile/tests/test_scaling_functional.py +++ b/plone/namedfile/tests/test_scaling_functional.py @@ -10,6 +10,7 @@ from plone.namedfile.testing import PLONE_NAMEDFILE_FUNCTIONAL_TESTING from plone.namedfile.tests import getFile from plone.testing.zope import Browser +from zExceptions import BadRequest from zope.annotation import IAttributeAnnotatable from zope.interface import implementer @@ -215,6 +216,11 @@ def testSVGPublishThumbViaName(self): self.assertEqual("image/svg+xml", self.browser.headers["content-type"]) self.assertEqual(self.browser.contents, data) + def testImagesViewWithNoSubpath(self): + transaction.commit() + with self.assertRaises(BadRequest): + self.browser.open(self.layer["app"].absolute_url() + "/item/@@images") + def test_suite(): from unittest import defaultTestLoader diff --git a/plone/namedfile/z3c-blobfile.zcml b/plone/namedfile/z3c-blobfile.zcml index 3b2ceb38..69a5312b 100644 --- a/plone/namedfile/z3c-blobfile.zcml +++ b/plone/namedfile/z3c-blobfile.zcml @@ -56,6 +56,12 @@ factory=".storages.PDataStorable" /> + + diff --git a/setup.py b/setup.py index 803bd58a..024a4a34 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ import os -version = "6.0.1" +version = "6.1.2.dev0" description = "File types and fields for images, files and blob files with " "filenames" long_description = "\n\n".join(