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"/>
-
+
"""
- 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(