diff --git a/last_commit.txt b/last_commit.txt index 8bd09aed02..6cd01305dc 100644 --- a/last_commit.txt +++ b/last_commit.txt @@ -1,44 +1,42 @@ -Repository: plone.restapi - - -Branch: refs/heads/main -Date: 2023-09-06T21:51:28-07:00 -Author: Leonardo J. Caballero G (macagua) -Commit: https://github.com/plone/plone.restapi/commit/ed192208499989b379de8534a3aa12d037316db4 - -Add Spanish translation #1684 (#1685) - -* Add Spanish translation #1684 - -* Updated Spanish translation - -* Fixed some typos from Spanish translation - -* Update Spanish translation - -* Add Spanish translation #1684 - -* Updated Spanish translation - -* Fixed some typos from Spanish translation - -* Update Spanish translation - -* maintenance: Added change log entry that missing - -* maintenance: Added change log entry that missing - -* Update news/1684.feature - ---------- - -Co-authored-by: Mikel Larreategi <mlarreategi@codesyntax.com> -Co-authored-by: David Glick <david@glicksoftware.com> +Repository: plone.namedfile + + +Branch: refs/heads/master +Date: 2023-09-11T10:57:01-04:00 +Author: Mathias Leimgruber (maethu) +Commit: https://github.com/plone/plone.namedfile/commit/056ad6ae630a9576198966b38b5e26786e573842 + +Add internal modification timestamp with fallback to _p_mtime. + +Files changed: +A news/149.internal +M plone/namedfile/file.py +M plone/namedfile/tests/__init__.py +M plone/namedfile/tests/test_blobfile.py +M plone/namedfile/tests/test_image.py +M plone/namedfile/tests/test_scaling.py + +b'diff --git a/news/149.internal b/news/149.internal\nnew file mode 100644\nindex 0000000..aa24e6a\n--- /dev/null\n+++ b/news/149.internal\n@@ -0,0 +1,2 @@\n+Add internal modification timestamp with fallback to _p_mtime.\n+[mathias.leimgruber]\ndiff --git a/plone/namedfile/file.py b/plone/namedfile/file.py\nindex f527aa1..36dd30b 100644\n--- a/plone/namedfile/file.py\n+++ b/plone/namedfile/file.py\n@@ -1,6 +1,7 @@\n # The implementations in this file are largely borrowed\n # from zope.app.file and z3c.blobfile\n # and are licensed under the ZPL.\n+from DateTime import DateTime\n from logging import getLogger\n from persistent import Persistent\n from plone.namedfile.interfaces import INamedBlobFile\n@@ -70,8 +71,17 @@ def _get_contents(self):\n pass\n \n \n+class ModifiedPropertyMixin:\n+ @property\n+ def modified(self):\n+ if hasattr(self, "_modified"):\n+ return self._modified\n+ # Fall back to modification time in database.\n+ return self._p_mtime\n+\n+\n @implementer(INamedFile)\n-class NamedFile(Persistent):\n+class NamedFile(Persistent, ModifiedPropertyMixin):\n """A non-BLOB file that stores a filename\n \n Let\'s test the constructor:\n@@ -169,6 +179,7 @@ def __init__(self, data=b"", contentType="", filename=None):\n self.data = data\n self.contentType = contentType\n self.filename = filename\n+ self._modified = DateTime().millis()\n \n def _getData(self):\n if isinstance(self._data, tuple(FILECHUNK_CLASSES)):\n@@ -177,6 +188,7 @@ def _getData(self):\n return self._data\n \n def _setData(self, data):\n+ self._modified = DateTime().millis()\n \n # Handle case when data is a string\n if isinstance(data, str):\n@@ -310,7 +322,7 @@ def getImageSize(self):\n \n \n @implementer(INamedBlobFile, HTTPRangeSupport.HTTPRangeInterface)\n-class NamedBlobFile(Persistent):\n+class NamedBlobFile(Persistent, ModifiedPropertyMixin):\n """A file stored in a ZODB BLOB, with a filename"""\n \n filename = FieldProperty(INamedFile["filename"])\n@@ -325,6 +337,7 @@ def __init__(self, data=b"", contentType="", filename=None):\n f.close()\n self._setData(data)\n self.filename = filename\n+ self._modified = DateTime().millis()\n \n def open(self, mode="r"):\n if mode != "r" and "size" in self.__dict__:\n@@ -342,6 +355,7 @@ def _setData(self, data):\n log.debug("Storage selected for data: %s", dottedName)\n storable = getUtility(IStorage, name=dottedName)\n storable.store(data, self._blob)\n+ self._modified = DateTime().millis()\n \n def _getData(self):\n fp = self._blob.open("r")\ndiff --git a/plone/namedfile/tests/__init__.py b/plone/namedfile/tests/__init__.py\nindex 16d30e5..b0aa490 100644\n--- a/plone/namedfile/tests/__init__.py\n+++ b/plone/namedfile/tests/__init__.py\n@@ -1,3 +1,8 @@\n+from DateTime import DateTime\n+from plone.namedfile.file import NamedBlobImage\n+from plone.namedfile.file import NamedImage\n+\n+\n import os\n \n \n@@ -6,3 +11,11 @@ def getFile(filename, length=None):\n filename = os.path.join(os.path.dirname(__file__), filename)\n with open(filename, "rb") as data_file:\n return data_file.read(length)\n+\n+\n+class MockNamedImage(NamedImage):\n+ _p_mtime = DateTime().millis()\n+\n+\n+class MockNamedBlobImage(NamedBlobImage):\n+ _p_mtime = DateTime().millis()\ndiff --git a/plone/namedfile/tests/test_blobfile.py b/plone/namedfile/tests/test_blobfile.py\nindex d748246..67f7107 100644\n--- a/plone/namedfile/tests/test_blobfile.py\n+++ b/plone/namedfile/tests/test_blobfile.py\n@@ -15,6 +15,7 @@\n #\n ##############################################################################\n \n+from DateTime import DateTime\n from plone.namedfile import storages\n from plone.namedfile.file import NamedBlobFile\n from plone.namedfile.file import NamedBlobImage\n@@ -24,11 +25,12 @@\n from plone.namedfile.testing import PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests.test_image import zptlogo\n+from plone.namedfile.tests import MockNamedBlobImage\n from zope.component import provideUtility\n from zope.interface.verify import verifyClass\n \n-import os\n import struct\n+import time\n import transaction\n import unittest\n \n@@ -56,11 +58,13 @@ def testEmpty(self):\n file = self._makeImage()\n self.assertEqual(file.contentType, "")\n self.assertEqual(file.data, b"")\n+ self.assertIsNotNone(file.modified)\n \n def testConstructor(self):\n file = self._makeImage(b"Data")\n self.assertEqual(file.contentType, "")\n self.assertEqual(file.data, b"Data")\n+ self.assertIsNotNone(file.modified)\n \n def testMutators(self):\n image = self._makeImage()\n@@ -73,6 +77,24 @@ def testMutators(self):\n self.assertEqual(image.contentType, "image/gif")\n self.assertEqual(image.getImageSize(), (16, 16))\n \n+ def testModifiedTimeStamp(self):\n+ image = self._makeImage()\n+ old_timestamp = image.modified\n+ time.sleep(1/1000) # make sure at least 1ms passes\n+ image._setData(zptlogo)\n+ self.assertNotEqual(image.modified, old_timestamp)\n+\n+ def testFallBackToDatabaseModifiedTimeStamp(self):\n+ dt = DateTime()\n+ image = MockNamedBlobImage()\n+ image._p_mtime = dt.millis()\n+ image._modified = (dt + 1).millis()\n+\n+ delattr(image, "_modified")\n+ marker = object()\n+ self.assertEqual(marker, getattr(image, "_modified", marker))\n+ self.assertEqual(dt.millis(), image._p_mtime)\n+\n def testInterface(self):\n self.assertTrue(INamedBlobImage.implementedBy(NamedBlobImage))\n self.assertTrue(verifyClass(INamedBlobImage, NamedBlobImage))\ndiff --git a/plone/namedfile/tests/test_image.py b/plone/namedfile/tests/test_image.py\nindex 4629207..8360a97 100644\n--- a/plone/namedfile/tests/test_image.py\n+++ b/plone/namedfile/tests/test_image.py\n@@ -1,12 +1,15 @@\n # This file is borrowed from zope.app.file and licensed ZPL.\n \n+from DateTime import DateTime\n from plone.namedfile.file import NamedImage\n from plone.namedfile.interfaces import INamedImage\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests import getFile\n+from plone.namedfile.tests import MockNamedImage\n from plone.namedfile.utils import get_contenttype\n from zope.interface.verify import verifyClass\n \n+import time\n import unittest\n \n \n@@ -39,15 +42,16 @@ def testEmpty(self):\n file_img = self._makeImage()\n self.assertEqual(file_img.contentType, "")\n self.assertEqual(bytes(file_img.data), b"")\n+ self.assertIsNotNone(file_img.modified)\n \n def testConstructor(self):\n file_img = self._makeImage(b"Data")\n self.assertEqual(file_img.contentType, "")\n self.assertEqual(bytes(file_img.data), b"Data")\n+ self.assertIsNotNone(file_img.modified)\n \n def testMutators(self):\n image = self._makeImage()\n-\n image.contentType = "image/jpeg"\n self.assertEqual(image.contentType, "image/jpeg")\n \n@@ -56,6 +60,24 @@ def testMutators(self):\n self.assertEqual(image.contentType, "image/gif")\n self.assertEqual(image.getImageSize(), (16, 16))\n \n+ def testModifiedTimeStamp(self):\n+ image = self._makeImage()\n+ old_timestamp = image.modified\n+ time.sleep(1/1000) # make sure at least 1ms passes\n+ image._setData(zptlogo)\n+ self.assertNotEqual(image.modified, old_timestamp)\n+\n+ def testFallBackToDatabaseModifiedTimeStamp(self):\n+ dt = DateTime()\n+ image = MockNamedImage()\n+ image._p_mtime = dt.millis()\n+ image._modified = (dt + 1).millis()\n+\n+ delattr(image, "_modified")\n+ marker = object()\n+ self.assertEqual(marker, getattr(image, "_modified", marker))\n+ self.assertEqual(dt.millis(), image._p_mtime)\n+\n def testInterface(self):\n self.assertTrue(INamedImage.implementedBy(NamedImage))\n self.assertTrue(verifyClass(INamedImage, NamedImage))\ndiff --git a/plone/namedfile/tests/test_scaling.py b/plone/namedfile/tests/test_scaling.py\nindex 7934763..4159ebf 100644\n--- a/plone/namedfile/tests/test_scaling.py\n+++ b/plone/namedfile/tests/test_scaling.py\n@@ -11,6 +11,7 @@\n from plone.namedfile.testing import PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests import getFile\n+from plone.namedfile.tests import MockNamedImage\n from plone.rfc822.interfaces import IPrimaryFieldInfo\n from plone.scale.interfaces import IScaledImageQuality\n from plone.scale.storage import IImageScaleStorage\n@@ -143,10 +144,6 @@ def value(self):\n return self.field\n \n \n-class MockNamedImage(NamedImage):\n- _p_mtime = DateTime().millis()\n-\n-\n @implementer(IScaledImageQuality)\n class DummyQualitySupplier:\n """fake utility for image quality setting from imaging control panel."""\n' + +Repository: plone.namedfile + + +Branch: refs/heads/master +Date: 2023-09-11T22:22:10-07:00 +Author: David Glick (davisagli) +Commit: https://github.com/plone/plone.namedfile/commit/28044bd2801a29a0460f7374252b9c65381a5c31 + +Merge pull request #150 from plone/mle-internal-modification-timestamp + +Add internal modification timestamp. Files changed: -A news/1684.feature -A src/plone/restapi/locales/es/LC_MESSAGES/plone.restapi.po -M CONTRIBUTORS.rst +A news/149.internal +M plone/namedfile/file.py +M plone/namedfile/tests/__init__.py +M plone/namedfile/tests/test_blobfile.py +M plone/namedfile/tests/test_image.py +M plone/namedfile/tests/test_scaling.py -b'diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst\nindex 35fb75b10b..4af50c3c97 100644\n--- a/CONTRIBUTORS.rst\n+++ b/CONTRIBUTORS.rst\n@@ -38,4 +38,4 @@\n - Gauthier Bastien\n - Katja S\xc3\xbcss\n - Jon Pentland\n- \n\\ No newline at end of file\n+- Leonardo J. Caballero G.\ndiff --git a/news/1684.feature b/news/1684.feature\nnew file mode 100644\nindex 0000000000..61ece574fb\n--- /dev/null\n+++ b/news/1684.feature\n@@ -0,0 +1 @@\n+Add Spanish translation @macagua\n\\ No newline at end of file\ndiff --git a/src/plone/restapi/locales/es/LC_MESSAGES/plone.restapi.po b/src/plone/restapi/locales/es/LC_MESSAGES/plone.restapi.po\nnew file mode 100644\nindex 0000000000..b957833eec\n--- /dev/null\n+++ b/src/plone/restapi/locales/es/LC_MESSAGES/plone.restapi.po\n@@ -0,0 +1,279 @@\n+# --- PLEASE EDIT THE LINES BELOW CORRECTLY ---\n+# SOME DESCRIPTIVE TITLE.\n+# Leonardo J. Caballero G. , 2019, 2023.\n+msgid ""\n+msgstr ""\n+"Project-Id-Version: Plone\\n"\n+"POT-Creation-Date: 2019-12-09 10:14+0000\\n"\n+"PO-Revision-Date: 2023-08-23 21:19-0400\\n"\n+"Last-Translator: Leonardo J. Caballero G. \\n"\n+"Language-Team: ES \\n"\n+"Language: es\\n"\n+"MIME-Version: 1.0\\n"\n+"Content-Type: text/plain; charset=UTF-8\\n"\n+"Content-Transfer-Encoding: 8bit\\n"\n+"Plural-Forms: nplurals=2; plural=(n != 1);\\n"\n+"Language-Code: es\\n"\n+"Language-Name: Espa\xc3\xb1ol\\n"\n+"Preferred-Encodings: utf-8 latin1\\n"\n+"Domain: plone.restapi\\n"\n+"X-Generator: Poedit 3.3.2\\n"\n+"X-Is-Fallback-For: es-ar es-bo es-cl es-co es-cr es-do es-ec es-es es-sv es-gt es-hn es-mx es-ni es-pa es-py es-pe es-pr es-us es-uy es-ve\\n"\n+\n+#: plone/restapi/services/email_send/post.py:81\n+msgid "${sender_fullname} via ${portal_title}"\n+msgstr "${sender_fullname} v\xc3\xada ${portal_title}"\n+\n+#: plone/restapi/services/email_send/post.py:74\n+msgid "A portal user via ${portal_title}"\n+msgstr "Un usuario de portal user v\xc3\xada ${portal_title}"\n+\n+#: plone/restapi/configure.zcml:73\n+msgid "Adds sample content for performance testing"\n+msgstr "A\xc3\xb1adir contenido de ejemplo para efectuar pruebas"\n+\n+#: plone/restapi/configure.zcml:56\n+msgid "Adds sample content types for testing"\n+msgstr "A\xc3\xb1adir tipos de contenido de ejemplo para pruebas"\n+\n+#: plone/restapi/configure.zcml:65\n+msgid "Adds sample workflows for testing"\n+msgstr "A\xc3\xb1adir flujos de trabajo de muestra para realizar pruebas"\n+\n+#: plone/restapi/services/aliases/add.py:106\n+msgid "Alternative urls that point to themselves will cause an endless cycle of redirects."\n+msgstr "Las URL alternativas que apuntan a s\xc3\xad mismas provocar\xc3\xa1n un bucle interminable de redireccionamientos."\n+\n+#: plone/restapi/configure.zcml:115\n+msgid "Blocks"\n+msgstr "Bloques"\n+\n+#: plone/restapi/configure.zcml:122\n+msgid "Blocks (Editable Layout)"\n+msgstr "Bloques (Dise\xc3\xb1o editable)"\n+\n+#: plone/restapi/configure.zcml:122\n+msgid "Enables Volto Blocks (editable layout) support"\n+msgstr "Habilita el soporte para bloques Volto (dise\xc3\xb1o editable)"\n+\n+#: plone/restapi/configure.zcml:115\n+msgid "Enables Volto Blocks support"\n+msgstr "Habilitar soporte a Bloques Volto"\n+\n+#: plone/restapi/configure.zcml:81\n+msgid "Enables blocks on the Document content type"\n+msgstr "Habilitar bloques en el tipo de contenido Documento"\n+\n+#: plone/restapi/services/contextnavigation/get.py:133\n+msgid "Enter a valid scale name (see \'Image Handling\' control panel) to override (e.g. icon, tile, thumb, mini, preview, ... ). Leave empty to use default (see \'Site\' control panel)."\n+msgstr "Introducir un nombre de escala v\xc3\xa1lido (consulte \'Manipulaci\xc3\xb3n de im\xc3\xa1genes\' en el panel de control) para anular (por ejemplo, icon, tile, thumb, mini, preview...). D\xc3\xa9jelo en blanco para usar el valor predeterminado (consulte \'Sitio\' en el panel de control)."\n+\n+#: plone/restapi/services/users/add.py:163\n+msgid "Error in fields. ${errors_to_string}"\n+msgstr "Errores en los campos. ${errors_to_string}"\n+\n+#. Default: "The reset_token is expired."\n+#: plone/restapi/services/users/add.py:312\n+msgid "Expired Token"\n+msgstr "Token caducado"\n+\n+#: plone/restapi/services/contextnavigation/get.py:126\n+msgid "If enabled, the portlet will not show document type icons."\n+msgstr "Si est\xc3\xa1 habilitado, el portlet no mostrar\xc3\xa1 los iconos de tipo de contenido."\n+\n+#: plone/restapi/services/contextnavigation/get.py:145\n+msgid "If enabled, the portlet will not show thumbs."\n+msgstr "Si est\xc3\xa1 habilitado, el portlet no mostrar\xc3\xa1 miniaturas."\n+\n+#: plone/restapi/services/users/add.py:296\n+msgid "If you pass \'old_password\' you have to pass \'new_password\'"\n+msgstr "Si introduce \'Contrase\xc3\xb1a anterior\', debe ingresar \'Contrase\xc3\xb1a nueva\'"\n+\n+#: plone/restapi/services/users/add.py:290\n+msgid "If you pass \'reset_token\' you have to pass \'new_password\'"\n+msgstr "Si introduce \'Restablecer token\' debe ingresar \'Nueva contrase\xc3\xb1a\'"\n+\n+#: plone/restapi/behaviors.py:23\n+msgid "Layout"\n+msgstr "Dise\xc3\xb1o"\n+\n+#: plone/restapi/services/contextnavigation/get.py:195\n+msgid "Navigation"\n+msgstr "Navegaci\xc3\xb3n"\n+\n+#: plone/restapi/services/contextnavigation/get.py:132\n+msgid "Override thumb scale"\n+msgstr "Anular escala de miniaturas"\n+\n+#: plone/restapi/services/users/add.py:97\n+msgid "Property \'${fieldname}\' is not allowed."\n+msgstr "El campo \'${fieldname}\' no est\xc3\xa1 permitido."\n+\n+#: plone/restapi/services/users/add.py:87\n+msgid "Property \'${fieldname}\' is required."\n+msgstr "El campo \'${fieldname}\' es obligatorio."\n+\n+#: plone/restapi/configure.zcml:89\n+msgid "RESTful hypermedia API for Plone - Uninstall"\n+msgstr "API hipermedia RESTful para Plone - Desinstalar"\n+\n+#: plone/restapi/configure.zcml:47\n+msgid "RESTful hypermedia API for Plone."\n+msgstr "API hipermedia RESTful para Plone."\n+\n+#: plone/restapi/services/history/patch.py:28\n+msgid "Reverted to revision ${version}"\n+msgstr "Revertido para revisi\xc3\xb3n ${version}"\n+\n+#: plone/restapi/services/users/add.py:48\n+msgid "Roles"\n+msgstr "Roles"\n+\n+#: plone/restapi/services/users/add.py:350\n+msgid "See the user endpoint documentation for the valid parameters."\n+msgstr "Consulte la documentaci\xc3\xb3n del usuario del terminal para conocer los par\xc3\xa1metros v\xc3\xa1lidos."\n+\n+#: plone/restapi/services/contextnavigation/get.py:125\n+msgid "Suppress Icons"\n+msgstr "Suprimir iconos"\n+\n+#: plone/restapi/services/contextnavigation/get.py:144\n+msgid "Suppress thumbs"\n+msgstr "Suprimir miniaturas"\n+\n+#: plone/restapi/services/users/add.py:342\n+msgid "The password passed as \'old_password\' is wrong."\n+msgstr "La contrase\xc3\xb1a pasada como \\"Contrase\xc3\xb1a anterior\\" no es v\xc3\xa1lida."\n+\n+#: plone/restapi/services/users/add.py:307\n+msgid "The reset_token is unknown/not valid."\n+msgstr "El reset_token es desconocido/no v\xc3\xa1lido."\n+\n+#: plone/restapi/configure.zcml:81\n+msgid "Volto Blocks"\n+msgstr "Bloques Volto"\n+\n+#: plone/restapi/services/users/update.py:119\n+msgid "You are not authorized to perform this action"\n+msgstr "No est\xc3\xa1 autorizado a realizar esta acci\xc3\xb3n."\n+\n+#: plone/restapi/services/email_send/post.py:91\n+msgid "You are receiving this mail because ${sender_fullname} sent this message via the site ${portal_title}:"\n+msgstr "Est\xc3\xa1 recibiendo este correo porque ${sender_fullname} envi\xc3\xb3 este mensaje a trav\xc3\xa9s del sitio ${portal_title}:"\n+\n+#: plone/restapi/services/users/add.py:114\n+msgid "You can\'t send both password and sendPasswordReset."\n+msgstr "No puede enviar la contrase\xc3\xb1a y \'Enviar un correo electr\xc3\xb3nico de confirmaci\xc3\xb3n con un enlace para establecer la contrase\xc3\xb1a\'."\n+\n+#: plone/restapi/services/users/add.py:322\n+msgid "You can\'t set a password without a password reset token."\n+msgstr "No puede establecer una contrase\xc3\xb1a sin un token de restablecimiento de contrase\xc3\xb1a."\n+\n+#: plone/restapi/services/users/update.py:125\n+msgid "You can\'t update the properties of this user"\n+msgstr "No puede actualizar las propiedades de este usuario."\n+\n+#: plone/restapi/services/users/add.py:284\n+msgid "You can\'t use \'reset_token\' and \'old_password\' together."\n+msgstr "No puede utilizar \'Restablecer token\' y \'Contrase\xc3\xb1a anterior\' juntas."\n+\n+#: plone/restapi/services/users/add.py:109\n+msgid "You have to either send a password or sendPasswordReset."\n+msgstr "Debe enviar una contrase\xc3\xb1a o \'Enviar un correo electr\xc3\xb3nico de confirmaci\xc3\xb3n con un enlace para establecer la contrase\xc3\xb1a\'."\n+\n+#: plone/restapi/services/users/add.py:154\n+msgid "You need AddPortalMember permission."\n+msgstr "Necesita el permiso AddPortalMember."\n+\n+#: plone/restapi/services/users/add.py:329\n+msgid "You need to be logged in as the user \'${username}\' to set the password."\n+msgstr "Debe iniciar sesi\xc3\xb3n como usuario \'${username}\' para poder establecer la contrase\xc3\xb1a."\n+\n+#. Default: "Missing dependency"\n+#: plone/restapi/services/addons/addons.py:207\n+msgid "dependency_missing"\n+msgstr "Dependencia faltante"\n+\n+#. Default: "If selected, the navigation tree will only show the current folder and its children at all times."\n+#: plone/restapi/services/contextnavigation/get.py:84\n+msgid "help_current_folder_only"\n+msgstr "Si se selecciona, el \xc3\xa1rbol de navegaci\xc3\xb3n siempre mostrar\xc3\xa1 solo la carpeta actual y sus hijos."\n+\n+#. Default: "Whether or not to show the top, or \'root\', node in the navigation tree. This is affected by the \'Start level\' setting."\n+#: plone/restapi/services/contextnavigation/get.py:69\n+msgid "help_include_top_node"\n+msgstr "Si el nodo superior, o \'ra\xc3\xadz\', debe mostrarse o no en el \xc3\xa1rbol de navegaci\xc3\xb3n. Esto se ve afectado por la configuraci\xc3\xb3n del \'Nivel inicial\'."\n+\n+#. Default: "You may search for and choose a folder to act as the root of the navigation tree. Leave blank to use the Plone site root."\n+#: plone/restapi/services/contextnavigation/get.py:58\n+msgid "help_navigation_root"\n+msgstr "Puede buscar y seleccionar una carpeta para que act\xc3\xbae como ra\xc3\xadz del \xc3\xa1rbol de navegaci\xc3\xb3n. D\xc3\xa9jelo en blanco para usar la ra\xc3\xadz del sitio Plone."\n+\n+#. Default: "An integer value that specifies the number of folder levels below the site root that must be exceeded before the navigation tree will display. 0 means that the navigation tree should be displayed everywhere including pages in the root of the site. 1 means the tree only shows up inside folders located in the root and downwards, never showing at the top level."\n+#: plone/restapi/services/contextnavigation/get.py:96\n+msgid "help_navigation_start_level"\n+msgstr "Un valor entero que especifica el n\xc3\xbamero de niveles de carpeta debajo de la ra\xc3\xadz del sitio que se deben exceder antes de que se muestre el \xc3\xa1rbol de navegaci\xc3\xb3n. 0 significa que el \xc3\xa1rbol de navegaci\xc3\xb3n debe mostrarse en cualquier lugar, incluidas las p\xc3\xa1ginas en la ra\xc3\xadz del sitio. 1 significa que el \xc3\xa1rbol solo aparecer\xc3\xa1 dentro de las carpetas ubicadas en la ra\xc3\xadz y debajo de ella, y nunca aparecer\xc3\xa1 en el nivel ra\xc3\xadz."\n+\n+#. Default: "The title of the navigation tree."\n+#: plone/restapi/services/contextnavigation/get.py:49\n+msgid "help_navigation_title"\n+msgstr "El t\xc3\xadtulo del \xc3\xa1rbol de navegaci\xc3\xb3n."\n+\n+#. Default: "How many folders should be included before the navigation tree stops. 0 means no limit. 1 only includes the root folder."\n+#: plone/restapi/services/contextnavigation/get.py:113\n+msgid "help_navigation_tree_depth"\n+msgstr "Cu\xc3\xa1ntas carpetas se deben incluir antes de que se detenga el \xc3\xa1rbol de navegaci\xc3\xb3n. 0 significa sin l\xc3\xadmite. 1 significa incluir solo la carpeta ra\xc3\xadz."\n+\n+#. Default: "Only show the contents of the current folder."\n+#: plone/restapi/services/contextnavigation/get.py:80\n+msgid "label_current_folder_only"\n+msgstr "Muestra solo el contenido de la carpeta actual."\n+\n+#. Default: "Include top node"\n+#: plone/restapi/services/contextnavigation/get.py:68\n+msgid "label_include_top_node"\n+msgstr "Incluir nodo superior"\n+\n+#. Default: "Root node"\n+#: plone/restapi/services/contextnavigation/get.py:57\n+msgid "label_navigation_root_path"\n+msgstr "Nodo ra\xc3\xadz"\n+\n+#. Default: "Start level"\n+#: plone/restapi/services/contextnavigation/get.py:95\n+msgid "label_navigation_startlevel"\n+msgstr "Nivel inicial"\n+\n+#. Default: "Title"\n+#: plone/restapi/services/contextnavigation/get.py:48\n+msgid "label_navigation_title"\n+msgstr "T\xc3\xadtulo"\n+\n+#. Default: "Navigation tree depth"\n+#: plone/restapi/services/contextnavigation/get.py:112\n+msgid "label_navigation_tree_depth"\n+msgstr "Profundidad del \xc3\xa1rbol de navegaci\xc3\xb3n"\n+\n+#: plone/restapi/configure.zcml:47\n+msgid "plone.restapi"\n+msgstr "plone.restapi"\n+\n+#: plone/restapi/configure.zcml:73\n+msgid "plone.restapi performance testing"\n+msgstr "plone.restapi - prueba de rendimiento"\n+\n+#: plone/restapi/configure.zcml:56\n+msgid "plone.restapi testing"\n+msgstr "plone.restapi - pruebas"\n+\n+#: plone/restapi/configure.zcml:65\n+msgid "plone.restapi testing-workflows"\n+msgstr "plone.restapi - pruebas de flujos de trabajo"\n+\n+#: plone/restapi/upgrades/configure.zcml:33\n+msgid "plone.restapi.upgrades.0002"\n+msgstr "plone.restapi.upgrades.0002"\n+\n+#: plone/restapi/upgrades/configure.zcml:52\n+msgid "plone.restapi.upgrades.0004"\n+msgstr "plone.restapi.upgrades.0004"\n' +b'diff --git a/news/149.internal b/news/149.internal\nnew file mode 100644\nindex 0000000..aa24e6a\n--- /dev/null\n+++ b/news/149.internal\n@@ -0,0 +1,2 @@\n+Add internal modification timestamp with fallback to _p_mtime.\n+[mathias.leimgruber]\ndiff --git a/plone/namedfile/file.py b/plone/namedfile/file.py\nindex f527aa1..36dd30b 100644\n--- a/plone/namedfile/file.py\n+++ b/plone/namedfile/file.py\n@@ -1,6 +1,7 @@\n # The implementations in this file are largely borrowed\n # from zope.app.file and z3c.blobfile\n # and are licensed under the ZPL.\n+from DateTime import DateTime\n from logging import getLogger\n from persistent import Persistent\n from plone.namedfile.interfaces import INamedBlobFile\n@@ -70,8 +71,17 @@ def _get_contents(self):\n pass\n \n \n+class ModifiedPropertyMixin:\n+ @property\n+ def modified(self):\n+ if hasattr(self, "_modified"):\n+ return self._modified\n+ # Fall back to modification time in database.\n+ return self._p_mtime\n+\n+\n @implementer(INamedFile)\n-class NamedFile(Persistent):\n+class NamedFile(Persistent, ModifiedPropertyMixin):\n """A non-BLOB file that stores a filename\n \n Let\'s test the constructor:\n@@ -169,6 +179,7 @@ def __init__(self, data=b"", contentType="", filename=None):\n self.data = data\n self.contentType = contentType\n self.filename = filename\n+ self._modified = DateTime().millis()\n \n def _getData(self):\n if isinstance(self._data, tuple(FILECHUNK_CLASSES)):\n@@ -177,6 +188,7 @@ def _getData(self):\n return self._data\n \n def _setData(self, data):\n+ self._modified = DateTime().millis()\n \n # Handle case when data is a string\n if isinstance(data, str):\n@@ -310,7 +322,7 @@ def getImageSize(self):\n \n \n @implementer(INamedBlobFile, HTTPRangeSupport.HTTPRangeInterface)\n-class NamedBlobFile(Persistent):\n+class NamedBlobFile(Persistent, ModifiedPropertyMixin):\n """A file stored in a ZODB BLOB, with a filename"""\n \n filename = FieldProperty(INamedFile["filename"])\n@@ -325,6 +337,7 @@ def __init__(self, data=b"", contentType="", filename=None):\n f.close()\n self._setData(data)\n self.filename = filename\n+ self._modified = DateTime().millis()\n \n def open(self, mode="r"):\n if mode != "r" and "size" in self.__dict__:\n@@ -342,6 +355,7 @@ def _setData(self, data):\n log.debug("Storage selected for data: %s", dottedName)\n storable = getUtility(IStorage, name=dottedName)\n storable.store(data, self._blob)\n+ self._modified = DateTime().millis()\n \n def _getData(self):\n fp = self._blob.open("r")\ndiff --git a/plone/namedfile/tests/__init__.py b/plone/namedfile/tests/__init__.py\nindex 16d30e5..b0aa490 100644\n--- a/plone/namedfile/tests/__init__.py\n+++ b/plone/namedfile/tests/__init__.py\n@@ -1,3 +1,8 @@\n+from DateTime import DateTime\n+from plone.namedfile.file import NamedBlobImage\n+from plone.namedfile.file import NamedImage\n+\n+\n import os\n \n \n@@ -6,3 +11,11 @@ def getFile(filename, length=None):\n filename = os.path.join(os.path.dirname(__file__), filename)\n with open(filename, "rb") as data_file:\n return data_file.read(length)\n+\n+\n+class MockNamedImage(NamedImage):\n+ _p_mtime = DateTime().millis()\n+\n+\n+class MockNamedBlobImage(NamedBlobImage):\n+ _p_mtime = DateTime().millis()\ndiff --git a/plone/namedfile/tests/test_blobfile.py b/plone/namedfile/tests/test_blobfile.py\nindex d748246..67f7107 100644\n--- a/plone/namedfile/tests/test_blobfile.py\n+++ b/plone/namedfile/tests/test_blobfile.py\n@@ -15,6 +15,7 @@\n #\n ##############################################################################\n \n+from DateTime import DateTime\n from plone.namedfile import storages\n from plone.namedfile.file import NamedBlobFile\n from plone.namedfile.file import NamedBlobImage\n@@ -24,11 +25,12 @@\n from plone.namedfile.testing import PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests.test_image import zptlogo\n+from plone.namedfile.tests import MockNamedBlobImage\n from zope.component import provideUtility\n from zope.interface.verify import verifyClass\n \n-import os\n import struct\n+import time\n import transaction\n import unittest\n \n@@ -56,11 +58,13 @@ def testEmpty(self):\n file = self._makeImage()\n self.assertEqual(file.contentType, "")\n self.assertEqual(file.data, b"")\n+ self.assertIsNotNone(file.modified)\n \n def testConstructor(self):\n file = self._makeImage(b"Data")\n self.assertEqual(file.contentType, "")\n self.assertEqual(file.data, b"Data")\n+ self.assertIsNotNone(file.modified)\n \n def testMutators(self):\n image = self._makeImage()\n@@ -73,6 +77,24 @@ def testMutators(self):\n self.assertEqual(image.contentType, "image/gif")\n self.assertEqual(image.getImageSize(), (16, 16))\n \n+ def testModifiedTimeStamp(self):\n+ image = self._makeImage()\n+ old_timestamp = image.modified\n+ time.sleep(1/1000) # make sure at least 1ms passes\n+ image._setData(zptlogo)\n+ self.assertNotEqual(image.modified, old_timestamp)\n+\n+ def testFallBackToDatabaseModifiedTimeStamp(self):\n+ dt = DateTime()\n+ image = MockNamedBlobImage()\n+ image._p_mtime = dt.millis()\n+ image._modified = (dt + 1).millis()\n+\n+ delattr(image, "_modified")\n+ marker = object()\n+ self.assertEqual(marker, getattr(image, "_modified", marker))\n+ self.assertEqual(dt.millis(), image._p_mtime)\n+\n def testInterface(self):\n self.assertTrue(INamedBlobImage.implementedBy(NamedBlobImage))\n self.assertTrue(verifyClass(INamedBlobImage, NamedBlobImage))\ndiff --git a/plone/namedfile/tests/test_image.py b/plone/namedfile/tests/test_image.py\nindex 4629207..8360a97 100644\n--- a/plone/namedfile/tests/test_image.py\n+++ b/plone/namedfile/tests/test_image.py\n@@ -1,12 +1,15 @@\n # This file is borrowed from zope.app.file and licensed ZPL.\n \n+from DateTime import DateTime\n from plone.namedfile.file import NamedImage\n from plone.namedfile.interfaces import INamedImage\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests import getFile\n+from plone.namedfile.tests import MockNamedImage\n from plone.namedfile.utils import get_contenttype\n from zope.interface.verify import verifyClass\n \n+import time\n import unittest\n \n \n@@ -39,15 +42,16 @@ def testEmpty(self):\n file_img = self._makeImage()\n self.assertEqual(file_img.contentType, "")\n self.assertEqual(bytes(file_img.data), b"")\n+ self.assertIsNotNone(file_img.modified)\n \n def testConstructor(self):\n file_img = self._makeImage(b"Data")\n self.assertEqual(file_img.contentType, "")\n self.assertEqual(bytes(file_img.data), b"Data")\n+ self.assertIsNotNone(file_img.modified)\n \n def testMutators(self):\n image = self._makeImage()\n-\n image.contentType = "image/jpeg"\n self.assertEqual(image.contentType, "image/jpeg")\n \n@@ -56,6 +60,24 @@ def testMutators(self):\n self.assertEqual(image.contentType, "image/gif")\n self.assertEqual(image.getImageSize(), (16, 16))\n \n+ def testModifiedTimeStamp(self):\n+ image = self._makeImage()\n+ old_timestamp = image.modified\n+ time.sleep(1/1000) # make sure at least 1ms passes\n+ image._setData(zptlogo)\n+ self.assertNotEqual(image.modified, old_timestamp)\n+\n+ def testFallBackToDatabaseModifiedTimeStamp(self):\n+ dt = DateTime()\n+ image = MockNamedImage()\n+ image._p_mtime = dt.millis()\n+ image._modified = (dt + 1).millis()\n+\n+ delattr(image, "_modified")\n+ marker = object()\n+ self.assertEqual(marker, getattr(image, "_modified", marker))\n+ self.assertEqual(dt.millis(), image._p_mtime)\n+\n def testInterface(self):\n self.assertTrue(INamedImage.implementedBy(NamedImage))\n self.assertTrue(verifyClass(INamedImage, NamedImage))\ndiff --git a/plone/namedfile/tests/test_scaling.py b/plone/namedfile/tests/test_scaling.py\nindex 7934763..4159ebf 100644\n--- a/plone/namedfile/tests/test_scaling.py\n+++ b/plone/namedfile/tests/test_scaling.py\n@@ -11,6 +11,7 @@\n from plone.namedfile.testing import PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests import getFile\n+from plone.namedfile.tests import MockNamedImage\n from plone.rfc822.interfaces import IPrimaryFieldInfo\n from plone.scale.interfaces import IScaledImageQuality\n from plone.scale.storage import IImageScaleStorage\n@@ -143,10 +144,6 @@ def value(self):\n return self.field\n \n \n-class MockNamedImage(NamedImage):\n- _p_mtime = DateTime().millis()\n-\n-\n @implementer(IScaledImageQuality)\n class DummyQualitySupplier:\n """fake utility for image quality setting from imaging control panel."""\n'