diff --git a/last_commit.txt b/last_commit.txt index 560f7f7e0f..b40ba2f859 100644 --- a/last_commit.txt +++ b/last_commit.txt @@ -1,53 +1,52 @@ -Repository: plone.exportimport +Repository: plone.app.event -Branch: refs/heads/main -Date: 2025-01-22T16:17:49+01:00 -Author: Maurits van Rees (mauritsvanrees) -Commit: https://github.com/plone/plone.exportimport/commit/1c35a490be5dee5de6f3ebcf7374a4a568b842e4 - -Import: update dates again at the end. +Branch: refs/heads/master +Date: 2025-01-06T20:55:15Z +Author: pre-commit-ci[bot] (pre-commit-ci[bot]) <66853113+pre-commit-ci[bot]@users.noreply.github.com> +Commit: https://github.com/plone/plone.app.event/commit/38429b35111d9e5b075bbd9f18c78c06087a2c48 -The original modification dates may have changed. +[pre-commit.ci] pre-commit autoupdate -We save the original modification date on the object during the content import, and use this in the final importer. -This is what collective.exportimport does as well. +updates: +- [github.com/asottile/pyupgrade: v3.17.0 → v3.19.1](https://github.com/asottile/pyupgrade/compare/v3.17.0...v3.19.1) +- [github.com/mgedmin/check-manifest: 0.49 → 0.50](https://github.com/mgedmin/check-manifest/compare/0.49...0.50) +- [github.com/mgedmin/check-python-versions: 0.22.0 → 0.22.1](https://github.com/mgedmin/check-python-versions/compare/0.22.0...0.22.1) Files changed: -A news/39.bugfix.2 -A src/plone/exportimport/importers/final.py -A tests/importers/test_importers_final.py -M src/plone/exportimport/importers/__init__.py -M src/plone/exportimport/importers/base.py -M src/plone/exportimport/importers/configure.zcml -M src/plone/exportimport/utils/content/__init__.py -M src/plone/exportimport/utils/content/import_helpers.py -M tests/importers/test_importers.py +M .pre-commit-config.yaml -b'diff --git a/news/39.bugfix.2 b/news/39.bugfix.2\nnew file mode 100644\nindex 0000000..4e3f3a9\n--- /dev/null\n+++ b/news/39.bugfix.2\n@@ -0,0 +1 @@\n+Import: update modification dates again at the end. The original modification dates may have changed. @mauritsvanrees\ndiff --git a/src/plone/exportimport/importers/__init__.py b/src/plone/exportimport/importers/__init__.py\nindex 426c403..2a42e49 100644\n--- a/src/plone/exportimport/importers/__init__.py\n+++ b/src/plone/exportimport/importers/__init__.py\n@@ -20,6 +20,7 @@\n "plone.importer.translations",\n "plone.importer.discussions",\n "plone.importer.portlets",\n+ "plone.importer.final",\n ]\n \n ImporterMapping = Dict[str, BaseImporter]\ndiff --git a/src/plone/exportimport/importers/base.py b/src/plone/exportimport/importers/base.py\nindex caf1ff8..10d550d 100644\n--- a/src/plone/exportimport/importers/base.py\n+++ b/src/plone/exportimport/importers/base.py\n@@ -69,3 +69,25 @@ def import_data(\n self.obj_hooks = self.obj_hooks or obj_hooks or []\n report = self.do_import()\n return report\n+\n+\n+class BaseDatalessImporter(BaseImporter):\n+ """Base for an import that does not read json data files.\n+\n+ Generally this would iterate over all existing content objects and do\n+ some updates.\n+ """\n+\n+ def import_data(\n+ self,\n+ base_path: Path,\n+ data_hooks: List[Callable] = None,\n+ pre_deserialize_hooks: List[Callable] = None,\n+ obj_hooks: List[Callable] = None,\n+ ) -> str:\n+ """Import data into a Plone site.\n+\n+ Note that we ignore the json data related arguments.\n+ """\n+ self.obj_hooks = self.obj_hooks or obj_hooks or []\n+ return self.do_import()\ndiff --git a/src/plone/exportimport/importers/configure.zcml b/src/plone/exportimport/importers/configure.zcml\nindex 54d41b9..c385d83 100644\n--- a/src/plone/exportimport/importers/configure.zcml\n+++ b/src/plone/exportimport/importers/configure.zcml\n@@ -33,6 +33,12 @@\n for="plone.base.interfaces.siteroot.IPloneSiteRoot"\n name="plone.importer.relations"\n />\n+ \n \n str:\n+ count = 0\n+\n+ with request_provides(self.request, IExportImportRequestMarker):\n+ catalog = api.portal.get_tool("portal_catalog")\n+ # getAllBrains does not yet process the indexing queue before it starts.\n+ # It probably should. Let\'s call it explicitly here.\n+ processQueue()\n+ for brain in catalog.getAllBrains():\n+ obj = brain.getObject()\n+ logger_prefix = f"- {brain.getPath()}:"\n+ for updater in content_utils.final_updaters():\n+ logger.debug(f"{logger_prefix} Running {updater.name} for {obj}")\n+ updater.func(obj)\n+\n+ # Apply obj hooks\n+ for func in self.obj_hooks:\n+ logger.debug(\n+ f"{logger_prefix} Running object hook {func.__name__}"\n+ )\n+ obj = func(obj)\n+\n+ count += 1\n+ if not count % 100:\n+ transaction.savepoint()\n+ logger.info(f"Handled {count} items...")\n+\n+ report = f"{self.__class__.__name__}: Updated {count} objects"\n+ logger.info(report)\n+ return report\ndiff --git a/src/plone/exportimport/utils/content/__init__.py b/src/plone/exportimport/utils/content/__init__.py\nindex c2bf241..d1e0960 100644\n--- a/src/plone/exportimport/utils/content/__init__.py\n+++ b/src/plone/exportimport/utils/content/__init__.py\n@@ -10,6 +10,7 @@\n from .export_helpers import fixers # noQA\n from .export_helpers import get_serializer # noQA\n from .export_helpers import metadata_helpers # noQA\n+from .import_helpers import final_updaters # noQA\n from .import_helpers import get_deserializer # noQA\n from .import_helpers import get_obj_instance # noQA\n from .import_helpers import metadata_setters # noQA\ndiff --git a/src/plone/exportimport/utils/content/import_helpers.py b/src/plone/exportimport/utils/content/import_helpers.py\nindex 037bb69..800d9f6 100644\n--- a/src/plone/exportimport/utils/content/import_helpers.py\n+++ b/src/plone/exportimport/utils/content/import_helpers.py\n@@ -7,6 +7,7 @@\n from plone import api\n from plone.base.interfaces.constrains import ENABLED\n from plone.base.interfaces.constrains import ISelectableConstrainTypes\n+from plone.base.utils import base_hasattr\n from plone.base.utils import unrestricted_construct_instance\n from plone.dexterity.content import DexterityContent\n from plone.exportimport import logger\n@@ -163,7 +164,13 @@ def update_workflow_history(item: dict, obj: DexterityContent) -> DexterityConte\n \n \n def update_dates(item: dict, obj: DexterityContent) -> DexterityContent:\n- """Update creation and modification dates on the object."""\n+ """Update creation and modification dates on the object.\n+\n+ We call this last in our content updaters, because they have been changed.\n+\n+ The modification date may change again due to importers that run after us.\n+ So we save it on a temporary property for handling in the final importer.\n+ """\n created = item.get("created", item.get("creation_date", None))\n modified = item.get("modified", item.get("modification_date", None))\n idxs = []\n@@ -174,9 +181,32 @@ def update_dates(item: dict, obj: DexterityContent) -> DexterityContent:\n value = parse_date(value)\n if not value:\n continue\n+ if attr == "modification_date":\n+ # Make sure we never change an acquired attribute.\n+ aq_base(obj).modification_date_migrated = value\n+ old_value = getattr(obj, attr, None)\n+ if old_value == value:\n+ continue\n setattr(obj, attr, value)\n idxs.append(idx)\n- obj.reindexObject(idxs=idxs)\n+ if idxs:\n+ obj.reindexObject(idxs=idxs)\n+ return obj\n+\n+\n+def reset_modification_date(obj: DexterityContent) -> DexterityContent:\n+ """Update modification date if it was saved on the object.\n+\n+ The modification date of the object may have gotten changed in various\n+ importers. The content import has saved the original modification date\n+ on the object. Now restore it.\n+ """\n+ if base_hasattr(obj, "modification_date_migrated"):\n+ modified = obj.modification_date_migrated\n+ if modified and modified != obj.modification_date:\n+ obj.modification_date = modified\n+ del obj.modification_date_migrated\n+ obj.reindexObject(idxs=["modified"])\n return obj\n \n \n@@ -329,3 +359,19 @@ def recatalog_uids(uids: List[str], idxs: List[str]):\n if not obj:\n continue\n obj.reindexObject(idxs)\n+\n+\n+def final_updaters() -> List[types.ExportImportHelper]:\n+ updaters = []\n+ funcs = [\n+ reset_modification_date,\n+ ]\n+ for func in funcs:\n+ updaters.append(\n+ types.ExportImportHelper(\n+ func=func,\n+ name=func.__name__,\n+ description=func.__doc__,\n+ )\n+ )\n+ return updaters\ndiff --git a/tests/importers/test_importers.py b/tests/importers/test_importers.py\nindex 9de3e4a..83d095d 100644\n--- a/tests/importers/test_importers.py\n+++ b/tests/importers/test_importers.py\n@@ -1,5 +1,8 @@\n+from DateTime import DateTime\n+from plone import api\n from plone.exportimport.importers import get_importer\n from plone.exportimport.importers import Importer\n+from Products.CMFCore.indexing import processQueue\n \n import pytest\n \n@@ -27,6 +30,7 @@ def test_all_importers(self):\n "plone.importer.relations",\n "plone.importer.translations",\n "plone.importer.discussions",\n+ "plone.importer.final",\n ],\n )\n def test_importer_present(self, importer_name: str):\n@@ -39,6 +43,7 @@ def test_importer_present(self, importer_name: str):\n "ContentImporter: Imported 9 objects",\n "PrincipalsImporter: Imported 2 groups and 1 members",\n "RedirectsImporter: Imported 0 redirects",\n+ "FinalImporter: Updated 9 objects",\n ],\n )\n def test_import_site(self, base_import_path, msg: str):\n@@ -47,3 +52,73 @@ def test_import_site(self, base_import_path, msg: str):\n # One entry per importer\n assert len(results) >= 6\n assert msg in results\n+\n+ @pytest.mark.parametrize(\n+ "uid,method_name,value",\n+ [\n+ [\n+ "35661c9bb5be42c68f665aa1ed291418",\n+ "created",\n+ "2024-02-13T18:16:04+00:00",\n+ ],\n+ [\n+ "35661c9bb5be42c68f665aa1ed291418",\n+ "modified",\n+ "2024-02-13T18:16:04+00:00",\n+ ],\n+ [\n+ "e7359727ace64e609b79c4091c38822a",\n+ "created",\n+ "2024-02-13T18:15:56+00:00",\n+ ],\n+ # The next one would fail without the final importer.\n+ [\n+ "e7359727ace64e609b79c4091c38822a",\n+ "modified",\n+ "2024-02-13T20:51:06+00:00",\n+ ],\n+ ],\n+ )\n+ def test_date_is_set(self, base_import_path, uid, method_name, value):\n+ from plone.exportimport.utils.content import object_from_uid\n+\n+ self.importer.import_site(base_import_path)\n+ content = object_from_uid(uid)\n+ assert getattr(content, method_name)() == DateTime(value)\n+\n+ def test_final_contents(self, base_import_path):\n+ self.importer.import_site(base_import_path)\n+\n+ # First test that some specific contents were created.\n+ image = api.content.get(path="/bar/2025.png")\n+ assert image is not None\n+ assert image.portal_type == "Image"\n+ assert image.title == "2025 logo"\n+\n+ page = api.content.get(path="/foo/another-page")\n+ assert page is not None\n+ assert page.portal_type == "Document"\n+ assert page.title == "Another page"\n+\n+ # Now do general checks on all contents.\n+ catalog = api.portal.get_tool("portal_catalog")\n+\n+ # getAllBrains does not yet process the indexing queue before it starts.\n+ # It probably should. We call it explicitly here, otherwise the tests fail:\n+ # Some brains will have a modification date of today, even though if you get\n+ # the object, its actual modification date has been reset to 2024.\n+ processQueue()\n+ brains = list(catalog.getAllBrains())\n+ assert len(brains) >= 9\n+ for brain in brains:\n+ if brain.portal_type == "Plone Site":\n+ continue\n+ # All created and modified dates should be in the previous year\n+ # (or earlier).\n+ assert not brain.created.isCurrentYear()\n+ assert not brain.modified.isCurrentYear()\n+ # Given what we see with getAllBrains, let\'s check the actual content\n+ # items as well.\n+ obj = brain.getObject()\n+ assert not obj.created().isCurrentYear()\n+ assert not obj.modified().isCurrentYear()\ndiff --git a/tests/importers/test_importers_final.py b/tests/importers/test_importers_final.py\nnew file mode 100644\nindex 0000000..a1c0065\n--- /dev/null\n+++ b/tests/importers/test_importers_final.py\n@@ -0,0 +1,84 @@\n+from DateTime import DateTime\n+from plone.exportimport import interfaces\n+from plone.exportimport.importers import content\n+from plone.exportimport.importers import final\n+from zope.component import getAdapter\n+\n+import pytest\n+\n+\n+class TestImporterContent:\n+ @pytest.fixture(autouse=True)\n+ def _init(self, portal_multilingual_content):\n+ self.portal = portal_multilingual_content\n+ self.importer = final.FinalImporter(self.portal)\n+\n+ def test_adapter_is_registered(self):\n+ adapter = getAdapter(\n+ self.portal, interfaces.INamedImporter, "plone.importer.final"\n+ )\n+ assert isinstance(adapter, final.FinalImporter)\n+\n+ def test_output_is_str(self, multilingual_import_path):\n+ result = self.importer.import_data(base_path=multilingual_import_path)\n+ assert isinstance(result, str)\n+ assert result == "FinalImporter: Updated 19 objects"\n+\n+ def test_empty_import_path(self, empty_import_path):\n+ # The import path is ignored by this importer.\n+ result = self.importer.import_data(base_path=empty_import_path)\n+ assert isinstance(result, str)\n+ assert result == "FinalImporter: Updated 19 objects"\n+\n+\n+class TestImporterDates:\n+ @pytest.fixture(autouse=True)\n+ def _init(self, portal, base_import_path, load_json):\n+ self.portal = portal\n+ content_importer = content.ContentImporter(self.portal)\n+ content_importer.import_data(base_path=base_import_path)\n+ importer = final.FinalImporter(portal)\n+ importer.import_data(base_path=base_import_path)\n+\n+ @pytest.mark.parametrize(\n+ "uid,method_name,value",\n+ [\n+ [\n+ "35661c9bb5be42c68f665aa1ed291418",\n+ "created",\n+ "2024-02-13T18:16:04+00:00",\n+ ],\n+ [\n+ "35661c9bb5be42c68f665aa1ed291418",\n+ "modified",\n+ "2024-02-13T18:16:04+00:00",\n+ ],\n+ [\n+ "3e0dd7c4b2714eafa1d6fc6a1493f953",\n+ "created",\n+ "2024-03-19T19:02:18+00:00",\n+ ],\n+ [\n+ "3e0dd7c4b2714eafa1d6fc6a1493f953",\n+ "modified",\n+ "2024-03-19T19:02:18+00:00",\n+ ],\n+ [\n+ "e7359727ace64e609b79c4091c38822a",\n+ "created",\n+ "2024-02-13T18:15:56+00:00",\n+ ],\n+ # Note: this would fail without the final importer, because this\n+ # is a folder that gets modified later when a document is added.\n+ [\n+ "e7359727ace64e609b79c4091c38822a",\n+ "modified",\n+ "2024-02-13T20:51:06+00:00",\n+ ],\n+ ],\n+ )\n+ def test_date_is_set(self, uid, method_name, value):\n+ from plone.exportimport.utils.content import object_from_uid\n+\n+ content = object_from_uid(uid)\n+ assert getattr(content, method_name)() == DateTime(value)\n' +b'diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml\nindex f70417dc..18b75326 100644\n--- a/.pre-commit-config.yaml\n+++ b/.pre-commit-config.yaml\n@@ -7,7 +7,7 @@ ci:\n \n repos:\n - repo: https://github.com/asottile/pyupgrade\n- rev: v3.17.0\n+ rev: v3.19.1\n hooks:\n - id: pyupgrade\n args: [--py38-plus]\n@@ -58,7 +58,7 @@ repos:\n # """\n ##\n - repo: https://github.com/mgedmin/check-manifest\n- rev: "0.49"\n+ rev: "0.50"\n hooks:\n - id: check-manifest\n - repo: https://github.com/regebro/pyroma\n@@ -66,7 +66,7 @@ repos:\n hooks:\n - id: pyroma\n - repo: https://github.com/mgedmin/check-python-versions\n- rev: "0.22.0"\n+ rev: "0.22.1"\n hooks:\n - id: check-python-versions\n args: [\'--only\', \'setup.py,pyproject.toml\']\n' -Repository: plone.exportimport +Repository: plone.app.event -Branch: refs/heads/main -Date: 2025-01-23T16:24:21+01:00 +Branch: refs/heads/master +Date: 2025-01-23T16:27:16+01:00 Author: Maurits van Rees (mauritsvanrees) -Commit: https://github.com/plone/plone.exportimport/commit/0c08c81fa15fef92b905fa4a4e199b9322bb8340 +Commit: https://github.com/plone/plone.app.event/commit/df32d3ffbc6b5881d9a7ea105b2eb0ffe4816cb7 + +Merge remote-tracking branch 'origin/pre-commit-ci-update-config' + +Files changed: +M .pre-commit-config.yaml -Merge pull request #45 from plone/maurits-sustainable-exports-update-dates-at-end-take-two +b'diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml\nindex f70417dc..18b75326 100644\n--- a/.pre-commit-config.yaml\n+++ b/.pre-commit-config.yaml\n@@ -7,7 +7,7 @@ ci:\n \n repos:\n - repo: https://github.com/asottile/pyupgrade\n- rev: v3.17.0\n+ rev: v3.19.1\n hooks:\n - id: pyupgrade\n args: [--py38-plus]\n@@ -58,7 +58,7 @@ repos:\n # """\n ##\n - repo: https://github.com/mgedmin/check-manifest\n- rev: "0.49"\n+ rev: "0.50"\n hooks:\n - id: check-manifest\n - repo: https://github.com/regebro/pyroma\n@@ -66,7 +66,7 @@ repos:\n hooks:\n - id: pyroma\n - repo: https://github.com/mgedmin/check-python-versions\n- rev: "0.22.0"\n+ rev: "0.22.1"\n hooks:\n - id: check-python-versions\n args: [\'--only\', \'setup.py,pyproject.toml\']\n' -Import: update dates again at the end. Take 2. +Repository: plone.app.event + + +Branch: refs/heads/master +Date: 2025-01-23T16:30:06+01:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.app.event/commit/271514391fbaf8429601587365e1a0c577e70bbd + +update codespell Files changed: -A news/39.bugfix.2 -A src/plone/exportimport/importers/final.py -A tests/importers/test_importers_final.py -M src/plone/exportimport/importers/__init__.py -M src/plone/exportimport/importers/base.py -M src/plone/exportimport/importers/configure.zcml -M src/plone/exportimport/utils/content/__init__.py -M src/plone/exportimport/utils/content/import_helpers.py -M tests/importers/test_importers.py - -b'diff --git a/news/39.bugfix.2 b/news/39.bugfix.2\nnew file mode 100644\nindex 0000000..4e3f3a9\n--- /dev/null\n+++ b/news/39.bugfix.2\n@@ -0,0 +1 @@\n+Import: update modification dates again at the end. The original modification dates may have changed. @mauritsvanrees\ndiff --git a/src/plone/exportimport/importers/__init__.py b/src/plone/exportimport/importers/__init__.py\nindex 426c403..2a42e49 100644\n--- a/src/plone/exportimport/importers/__init__.py\n+++ b/src/plone/exportimport/importers/__init__.py\n@@ -20,6 +20,7 @@\n "plone.importer.translations",\n "plone.importer.discussions",\n "plone.importer.portlets",\n+ "plone.importer.final",\n ]\n \n ImporterMapping = Dict[str, BaseImporter]\ndiff --git a/src/plone/exportimport/importers/base.py b/src/plone/exportimport/importers/base.py\nindex caf1ff8..10d550d 100644\n--- a/src/plone/exportimport/importers/base.py\n+++ b/src/plone/exportimport/importers/base.py\n@@ -69,3 +69,25 @@ def import_data(\n self.obj_hooks = self.obj_hooks or obj_hooks or []\n report = self.do_import()\n return report\n+\n+\n+class BaseDatalessImporter(BaseImporter):\n+ """Base for an import that does not read json data files.\n+\n+ Generally this would iterate over all existing content objects and do\n+ some updates.\n+ """\n+\n+ def import_data(\n+ self,\n+ base_path: Path,\n+ data_hooks: List[Callable] = None,\n+ pre_deserialize_hooks: List[Callable] = None,\n+ obj_hooks: List[Callable] = None,\n+ ) -> str:\n+ """Import data into a Plone site.\n+\n+ Note that we ignore the json data related arguments.\n+ """\n+ self.obj_hooks = self.obj_hooks or obj_hooks or []\n+ return self.do_import()\ndiff --git a/src/plone/exportimport/importers/configure.zcml b/src/plone/exportimport/importers/configure.zcml\nindex 54d41b9..c385d83 100644\n--- a/src/plone/exportimport/importers/configure.zcml\n+++ b/src/plone/exportimport/importers/configure.zcml\n@@ -33,6 +33,12 @@\n for="plone.base.interfaces.siteroot.IPloneSiteRoot"\n name="plone.importer.relations"\n />\n+ \n \n str:\n+ count = 0\n+\n+ with request_provides(self.request, IExportImportRequestMarker):\n+ catalog = api.portal.get_tool("portal_catalog")\n+ # getAllBrains does not yet process the indexing queue before it starts.\n+ # It probably should. Let\'s call it explicitly here.\n+ processQueue()\n+ for brain in catalog.getAllBrains():\n+ obj = brain.getObject()\n+ logger_prefix = f"- {brain.getPath()}:"\n+ for updater in content_utils.final_updaters():\n+ logger.debug(f"{logger_prefix} Running {updater.name} for {obj}")\n+ updater.func(obj)\n+\n+ # Apply obj hooks\n+ for func in self.obj_hooks:\n+ logger.debug(\n+ f"{logger_prefix} Running object hook {func.__name__}"\n+ )\n+ obj = func(obj)\n+\n+ count += 1\n+ if not count % 100:\n+ transaction.savepoint()\n+ logger.info(f"Handled {count} items...")\n+\n+ report = f"{self.__class__.__name__}: Updated {count} objects"\n+ logger.info(report)\n+ return report\ndiff --git a/src/plone/exportimport/utils/content/__init__.py b/src/plone/exportimport/utils/content/__init__.py\nindex c2bf241..d1e0960 100644\n--- a/src/plone/exportimport/utils/content/__init__.py\n+++ b/src/plone/exportimport/utils/content/__init__.py\n@@ -10,6 +10,7 @@\n from .export_helpers import fixers # noQA\n from .export_helpers import get_serializer # noQA\n from .export_helpers import metadata_helpers # noQA\n+from .import_helpers import final_updaters # noQA\n from .import_helpers import get_deserializer # noQA\n from .import_helpers import get_obj_instance # noQA\n from .import_helpers import metadata_setters # noQA\ndiff --git a/src/plone/exportimport/utils/content/import_helpers.py b/src/plone/exportimport/utils/content/import_helpers.py\nindex cfea8c1..022a10a 100644\n--- a/src/plone/exportimport/utils/content/import_helpers.py\n+++ b/src/plone/exportimport/utils/content/import_helpers.py\n@@ -8,6 +8,7 @@\n from plone import api\n from plone.base.interfaces.constrains import ENABLED\n from plone.base.interfaces.constrains import ISelectableConstrainTypes\n+from plone.base.utils import base_hasattr\n from plone.base.utils import unrestricted_construct_instance\n from plone.dexterity.content import DexterityContent\n from plone.exportimport import logger\n@@ -168,7 +169,13 @@ def update_workflow_history(item: dict, obj: DexterityContent) -> DexterityConte\n \n \n def update_dates(item: dict, obj: DexterityContent) -> DexterityContent:\n- """Update creation and modification dates on the object."""\n+ """Update creation and modification dates on the object.\n+\n+ We call this last in our content updaters, because they have been changed.\n+\n+ The modification date may change again due to importers that run after us.\n+ So we save it on a temporary property for handling in the final importer.\n+ """\n created = item.get("created", item.get("creation_date", None))\n modified = item.get("modified", item.get("modification_date", None))\n idxs = []\n@@ -179,9 +186,32 @@ def update_dates(item: dict, obj: DexterityContent) -> DexterityContent:\n value = parse_date(value)\n if not value:\n continue\n+ if attr == "modification_date":\n+ # Make sure we never change an acquired attribute.\n+ aq_base(obj).modification_date_migrated = value\n+ old_value = getattr(obj, attr, None)\n+ if old_value == value:\n+ continue\n setattr(obj, attr, value)\n idxs.append(idx)\n- obj.reindexObject(idxs=idxs)\n+ if idxs:\n+ obj.reindexObject(idxs=idxs)\n+ return obj\n+\n+\n+def reset_modification_date(obj: DexterityContent) -> DexterityContent:\n+ """Update modification date if it was saved on the object.\n+\n+ The modification date of the object may have gotten changed in various\n+ importers. The content import has saved the original modification date\n+ on the object. Now restore it.\n+ """\n+ if base_hasattr(obj, "modification_date_migrated"):\n+ modified = obj.modification_date_migrated\n+ if modified and modified != obj.modification_date:\n+ obj.modification_date = modified\n+ del obj.modification_date_migrated\n+ obj.reindexObject(idxs=["modified"])\n return obj\n \n \n@@ -334,3 +364,19 @@ def recatalog_uids(uids: List[str], idxs: List[str]):\n if not obj:\n continue\n obj.reindexObject(idxs)\n+\n+\n+def final_updaters() -> List[types.ExportImportHelper]:\n+ updaters = []\n+ funcs = [\n+ reset_modification_date,\n+ ]\n+ for func in funcs:\n+ updaters.append(\n+ types.ExportImportHelper(\n+ func=func,\n+ name=func.__name__,\n+ description=func.__doc__,\n+ )\n+ )\n+ return updaters\ndiff --git a/tests/importers/test_importers.py b/tests/importers/test_importers.py\nindex 9de3e4a..83d095d 100644\n--- a/tests/importers/test_importers.py\n+++ b/tests/importers/test_importers.py\n@@ -1,5 +1,8 @@\n+from DateTime import DateTime\n+from plone import api\n from plone.exportimport.importers import get_importer\n from plone.exportimport.importers import Importer\n+from Products.CMFCore.indexing import processQueue\n \n import pytest\n \n@@ -27,6 +30,7 @@ def test_all_importers(self):\n "plone.importer.relations",\n "plone.importer.translations",\n "plone.importer.discussions",\n+ "plone.importer.final",\n ],\n )\n def test_importer_present(self, importer_name: str):\n@@ -39,6 +43,7 @@ def test_importer_present(self, importer_name: str):\n "ContentImporter: Imported 9 objects",\n "PrincipalsImporter: Imported 2 groups and 1 members",\n "RedirectsImporter: Imported 0 redirects",\n+ "FinalImporter: Updated 9 objects",\n ],\n )\n def test_import_site(self, base_import_path, msg: str):\n@@ -47,3 +52,73 @@ def test_import_site(self, base_import_path, msg: str):\n # One entry per importer\n assert len(results) >= 6\n assert msg in results\n+\n+ @pytest.mark.parametrize(\n+ "uid,method_name,value",\n+ [\n+ [\n+ "35661c9bb5be42c68f665aa1ed291418",\n+ "created",\n+ "2024-02-13T18:16:04+00:00",\n+ ],\n+ [\n+ "35661c9bb5be42c68f665aa1ed291418",\n+ "modified",\n+ "2024-02-13T18:16:04+00:00",\n+ ],\n+ [\n+ "e7359727ace64e609b79c4091c38822a",\n+ "created",\n+ "2024-02-13T18:15:56+00:00",\n+ ],\n+ # The next one would fail without the final importer.\n+ [\n+ "e7359727ace64e609b79c4091c38822a",\n+ "modified",\n+ "2024-02-13T20:51:06+00:00",\n+ ],\n+ ],\n+ )\n+ def test_date_is_set(self, base_import_path, uid, method_name, value):\n+ from plone.exportimport.utils.content import object_from_uid\n+\n+ self.importer.import_site(base_import_path)\n+ content = object_from_uid(uid)\n+ assert getattr(content, method_name)() == DateTime(value)\n+\n+ def test_final_contents(self, base_import_path):\n+ self.importer.import_site(base_import_path)\n+\n+ # First test that some specific contents were created.\n+ image = api.content.get(path="/bar/2025.png")\n+ assert image is not None\n+ assert image.portal_type == "Image"\n+ assert image.title == "2025 logo"\n+\n+ page = api.content.get(path="/foo/another-page")\n+ assert page is not None\n+ assert page.portal_type == "Document"\n+ assert page.title == "Another page"\n+\n+ # Now do general checks on all contents.\n+ catalog = api.portal.get_tool("portal_catalog")\n+\n+ # getAllBrains does not yet process the indexing queue before it starts.\n+ # It probably should. We call it explicitly here, otherwise the tests fail:\n+ # Some brains will have a modification date of today, even though if you get\n+ # the object, its actual modification date has been reset to 2024.\n+ processQueue()\n+ brains = list(catalog.getAllBrains())\n+ assert len(brains) >= 9\n+ for brain in brains:\n+ if brain.portal_type == "Plone Site":\n+ continue\n+ # All created and modified dates should be in the previous year\n+ # (or earlier).\n+ assert not brain.created.isCurrentYear()\n+ assert not brain.modified.isCurrentYear()\n+ # Given what we see with getAllBrains, let\'s check the actual content\n+ # items as well.\n+ obj = brain.getObject()\n+ assert not obj.created().isCurrentYear()\n+ assert not obj.modified().isCurrentYear()\ndiff --git a/tests/importers/test_importers_final.py b/tests/importers/test_importers_final.py\nnew file mode 100644\nindex 0000000..a1c0065\n--- /dev/null\n+++ b/tests/importers/test_importers_final.py\n@@ -0,0 +1,84 @@\n+from DateTime import DateTime\n+from plone.exportimport import interfaces\n+from plone.exportimport.importers import content\n+from plone.exportimport.importers import final\n+from zope.component import getAdapter\n+\n+import pytest\n+\n+\n+class TestImporterContent:\n+ @pytest.fixture(autouse=True)\n+ def _init(self, portal_multilingual_content):\n+ self.portal = portal_multilingual_content\n+ self.importer = final.FinalImporter(self.portal)\n+\n+ def test_adapter_is_registered(self):\n+ adapter = getAdapter(\n+ self.portal, interfaces.INamedImporter, "plone.importer.final"\n+ )\n+ assert isinstance(adapter, final.FinalImporter)\n+\n+ def test_output_is_str(self, multilingual_import_path):\n+ result = self.importer.import_data(base_path=multilingual_import_path)\n+ assert isinstance(result, str)\n+ assert result == "FinalImporter: Updated 19 objects"\n+\n+ def test_empty_import_path(self, empty_import_path):\n+ # The import path is ignored by this importer.\n+ result = self.importer.import_data(base_path=empty_import_path)\n+ assert isinstance(result, str)\n+ assert result == "FinalImporter: Updated 19 objects"\n+\n+\n+class TestImporterDates:\n+ @pytest.fixture(autouse=True)\n+ def _init(self, portal, base_import_path, load_json):\n+ self.portal = portal\n+ content_importer = content.ContentImporter(self.portal)\n+ content_importer.import_data(base_path=base_import_path)\n+ importer = final.FinalImporter(portal)\n+ importer.import_data(base_path=base_import_path)\n+\n+ @pytest.mark.parametrize(\n+ "uid,method_name,value",\n+ [\n+ [\n+ "35661c9bb5be42c68f665aa1ed291418",\n+ "created",\n+ "2024-02-13T18:16:04+00:00",\n+ ],\n+ [\n+ "35661c9bb5be42c68f665aa1ed291418",\n+ "modified",\n+ "2024-02-13T18:16:04+00:00",\n+ ],\n+ [\n+ "3e0dd7c4b2714eafa1d6fc6a1493f953",\n+ "created",\n+ "2024-03-19T19:02:18+00:00",\n+ ],\n+ [\n+ "3e0dd7c4b2714eafa1d6fc6a1493f953",\n+ "modified",\n+ "2024-03-19T19:02:18+00:00",\n+ ],\n+ [\n+ "e7359727ace64e609b79c4091c38822a",\n+ "created",\n+ "2024-02-13T18:15:56+00:00",\n+ ],\n+ # Note: this would fail without the final importer, because this\n+ # is a folder that gets modified later when a document is added.\n+ [\n+ "e7359727ace64e609b79c4091c38822a",\n+ "modified",\n+ "2024-02-13T20:51:06+00:00",\n+ ],\n+ ],\n+ )\n+ def test_date_is_set(self, uid, method_name, value):\n+ from plone.exportimport.utils.content import object_from_uid\n+\n+ content = object_from_uid(uid)\n+ assert getattr(content, method_name)() == DateTime(value)\n' +M .meta.toml +M .pre-commit-config.yaml +M pyproject.toml + +b'diff --git a/.meta.toml b/.meta.toml\nindex 2bc8a8da..8f9b2011 100644\n--- a/.meta.toml\n+++ b/.meta.toml\n@@ -7,7 +7,7 @@ commit-id = "6e36bcc4"\n \n [pyproject]\n dependencies_ignores = "[\'plone.app.layout\', \'Products.DateRecurringIndex\']"\n-codespell_ignores = "discreet,assertin"\n+codespell_ignores = "discreet,assertin,thet"\n \n [flake8]\n extra_lines = """\ndiff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml\nindex 18b75326..87388ce3 100644\n--- a/.pre-commit-config.yaml\n+++ b/.pre-commit-config.yaml\n@@ -44,7 +44,7 @@ repos:\n # """\n ##\n - repo: https://github.com/codespell-project/codespell\n- rev: v2.3.0\n+ rev: v2.4.0\n hooks:\n - id: codespell\n additional_dependencies:\ndiff --git a/pyproject.toml b/pyproject.toml\nindex ade70135..f7a83d4c 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -71,7 +71,7 @@ target-version = ["py38"]\n ##\n \n [tool.codespell]\n-ignore-words-list = "discreet,assertin"\n+ignore-words-list = "discreet,assertin,thet"\n skip = "*.po,"\n ##\n # Add extra configuration options in .meta.toml:\n'