Skip to content

Commit 0c08c81

Browse files
Merge pull request #45 from plone/maurits-sustainable-exports-update-dates-at-end-take-two
Import: update dates again at the end. Take 2.
2 parents d04511e + 1c35a49 commit 0c08c81

File tree

9 files changed

+282
-2
lines changed

9 files changed

+282
-2
lines changed

news/39.bugfix.2

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Import: update modification dates again at the end. The original modification dates may have changed. @mauritsvanrees

src/plone/exportimport/importers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"plone.importer.translations",
2121
"plone.importer.discussions",
2222
"plone.importer.portlets",
23+
"plone.importer.final",
2324
]
2425

2526
ImporterMapping = Dict[str, BaseImporter]

src/plone/exportimport/importers/base.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,25 @@ def import_data(
6969
self.obj_hooks = self.obj_hooks or obj_hooks or []
7070
report = self.do_import()
7171
return report
72+
73+
74+
class BaseDatalessImporter(BaseImporter):
75+
"""Base for an import that does not read json data files.
76+
77+
Generally this would iterate over all existing content objects and do
78+
some updates.
79+
"""
80+
81+
def import_data(
82+
self,
83+
base_path: Path,
84+
data_hooks: List[Callable] = None,
85+
pre_deserialize_hooks: List[Callable] = None,
86+
obj_hooks: List[Callable] = None,
87+
) -> str:
88+
"""Import data into a Plone site.
89+
90+
Note that we ignore the json data related arguments.
91+
"""
92+
self.obj_hooks = self.obj_hooks or obj_hooks or []
93+
return self.do_import()

src/plone/exportimport/importers/configure.zcml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
for="plone.base.interfaces.siteroot.IPloneSiteRoot"
3434
name="plone.importer.relations"
3535
/>
36+
<adapter
37+
factory=".final.FinalImporter"
38+
provides="plone.exportimport.interfaces.INamedImporter"
39+
for="plone.base.interfaces.siteroot.IPloneSiteRoot"
40+
name="plone.importer.final"
41+
/>
3642
<configure zcml:condition="installed plone.app.multilingual">
3743
<adapter
3844
factory=".translations.TranslationsImporter"
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from .base import BaseDatalessImporter
2+
from plone import api
3+
from plone.exportimport import logger
4+
from plone.exportimport.interfaces import IExportImportRequestMarker
5+
from plone.exportimport.utils import content as content_utils
6+
from plone.exportimport.utils import request_provides
7+
from Products.CMFCore.indexing import processQueue
8+
9+
import transaction
10+
11+
12+
class FinalImporter(BaseDatalessImporter):
13+
# name: str = ""
14+
15+
def do_import(self) -> str:
16+
count = 0
17+
18+
with request_provides(self.request, IExportImportRequestMarker):
19+
catalog = api.portal.get_tool("portal_catalog")
20+
# getAllBrains does not yet process the indexing queue before it starts.
21+
# It probably should. Let's call it explicitly here.
22+
processQueue()
23+
for brain in catalog.getAllBrains():
24+
obj = brain.getObject()
25+
logger_prefix = f"- {brain.getPath()}:"
26+
for updater in content_utils.final_updaters():
27+
logger.debug(f"{logger_prefix} Running {updater.name} for {obj}")
28+
updater.func(obj)
29+
30+
# Apply obj hooks
31+
for func in self.obj_hooks:
32+
logger.debug(
33+
f"{logger_prefix} Running object hook {func.__name__}"
34+
)
35+
obj = func(obj)
36+
37+
count += 1
38+
if not count % 100:
39+
transaction.savepoint()
40+
logger.info(f"Handled {count} items...")
41+
42+
report = f"{self.__class__.__name__}: Updated {count} objects"
43+
logger.info(report)
44+
return report

src/plone/exportimport/utils/content/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .export_helpers import fixers # noQA
1111
from .export_helpers import get_serializer # noQA
1212
from .export_helpers import metadata_helpers # noQA
13+
from .import_helpers import final_updaters # noQA
1314
from .import_helpers import get_deserializer # noQA
1415
from .import_helpers import get_obj_instance # noQA
1516
from .import_helpers import metadata_setters # noQA

src/plone/exportimport/utils/content/import_helpers.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from plone import api
99
from plone.base.interfaces.constrains import ENABLED
1010
from plone.base.interfaces.constrains import ISelectableConstrainTypes
11+
from plone.base.utils import base_hasattr
1112
from plone.base.utils import unrestricted_construct_instance
1213
from plone.dexterity.content import DexterityContent
1314
from plone.exportimport import logger
@@ -168,7 +169,13 @@ def update_workflow_history(item: dict, obj: DexterityContent) -> DexterityConte
168169

169170

170171
def update_dates(item: dict, obj: DexterityContent) -> DexterityContent:
171-
"""Update creation and modification dates on the object."""
172+
"""Update creation and modification dates on the object.
173+
174+
We call this last in our content updaters, because they have been changed.
175+
176+
The modification date may change again due to importers that run after us.
177+
So we save it on a temporary property for handling in the final importer.
178+
"""
172179
created = item.get("created", item.get("creation_date", None))
173180
modified = item.get("modified", item.get("modification_date", None))
174181
idxs = []
@@ -179,9 +186,32 @@ def update_dates(item: dict, obj: DexterityContent) -> DexterityContent:
179186
value = parse_date(value)
180187
if not value:
181188
continue
189+
if attr == "modification_date":
190+
# Make sure we never change an acquired attribute.
191+
aq_base(obj).modification_date_migrated = value
192+
old_value = getattr(obj, attr, None)
193+
if old_value == value:
194+
continue
182195
setattr(obj, attr, value)
183196
idxs.append(idx)
184-
obj.reindexObject(idxs=idxs)
197+
if idxs:
198+
obj.reindexObject(idxs=idxs)
199+
return obj
200+
201+
202+
def reset_modification_date(obj: DexterityContent) -> DexterityContent:
203+
"""Update modification date if it was saved on the object.
204+
205+
The modification date of the object may have gotten changed in various
206+
importers. The content import has saved the original modification date
207+
on the object. Now restore it.
208+
"""
209+
if base_hasattr(obj, "modification_date_migrated"):
210+
modified = obj.modification_date_migrated
211+
if modified and modified != obj.modification_date:
212+
obj.modification_date = modified
213+
del obj.modification_date_migrated
214+
obj.reindexObject(idxs=["modified"])
185215
return obj
186216

187217

@@ -334,3 +364,19 @@ def recatalog_uids(uids: List[str], idxs: List[str]):
334364
if not obj:
335365
continue
336366
obj.reindexObject(idxs)
367+
368+
369+
def final_updaters() -> List[types.ExportImportHelper]:
370+
updaters = []
371+
funcs = [
372+
reset_modification_date,
373+
]
374+
for func in funcs:
375+
updaters.append(
376+
types.ExportImportHelper(
377+
func=func,
378+
name=func.__name__,
379+
description=func.__doc__,
380+
)
381+
)
382+
return updaters

tests/importers/test_importers.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
from DateTime import DateTime
2+
from plone import api
13
from plone.exportimport.importers import get_importer
24
from plone.exportimport.importers import Importer
5+
from Products.CMFCore.indexing import processQueue
36

47
import pytest
58

@@ -27,6 +30,7 @@ def test_all_importers(self):
2730
"plone.importer.relations",
2831
"plone.importer.translations",
2932
"plone.importer.discussions",
33+
"plone.importer.final",
3034
],
3135
)
3236
def test_importer_present(self, importer_name: str):
@@ -39,6 +43,7 @@ def test_importer_present(self, importer_name: str):
3943
"ContentImporter: Imported 9 objects",
4044
"PrincipalsImporter: Imported 2 groups and 1 members",
4145
"RedirectsImporter: Imported 0 redirects",
46+
"FinalImporter: Updated 9 objects",
4247
],
4348
)
4449
def test_import_site(self, base_import_path, msg: str):
@@ -47,3 +52,73 @@ def test_import_site(self, base_import_path, msg: str):
4752
# One entry per importer
4853
assert len(results) >= 6
4954
assert msg in results
55+
56+
@pytest.mark.parametrize(
57+
"uid,method_name,value",
58+
[
59+
[
60+
"35661c9bb5be42c68f665aa1ed291418",
61+
"created",
62+
"2024-02-13T18:16:04+00:00",
63+
],
64+
[
65+
"35661c9bb5be42c68f665aa1ed291418",
66+
"modified",
67+
"2024-02-13T18:16:04+00:00",
68+
],
69+
[
70+
"e7359727ace64e609b79c4091c38822a",
71+
"created",
72+
"2024-02-13T18:15:56+00:00",
73+
],
74+
# The next one would fail without the final importer.
75+
[
76+
"e7359727ace64e609b79c4091c38822a",
77+
"modified",
78+
"2024-02-13T20:51:06+00:00",
79+
],
80+
],
81+
)
82+
def test_date_is_set(self, base_import_path, uid, method_name, value):
83+
from plone.exportimport.utils.content import object_from_uid
84+
85+
self.importer.import_site(base_import_path)
86+
content = object_from_uid(uid)
87+
assert getattr(content, method_name)() == DateTime(value)
88+
89+
def test_final_contents(self, base_import_path):
90+
self.importer.import_site(base_import_path)
91+
92+
# First test that some specific contents were created.
93+
image = api.content.get(path="/bar/2025.png")
94+
assert image is not None
95+
assert image.portal_type == "Image"
96+
assert image.title == "2025 logo"
97+
98+
page = api.content.get(path="/foo/another-page")
99+
assert page is not None
100+
assert page.portal_type == "Document"
101+
assert page.title == "Another page"
102+
103+
# Now do general checks on all contents.
104+
catalog = api.portal.get_tool("portal_catalog")
105+
106+
# getAllBrains does not yet process the indexing queue before it starts.
107+
# It probably should. We call it explicitly here, otherwise the tests fail:
108+
# Some brains will have a modification date of today, even though if you get
109+
# the object, its actual modification date has been reset to 2024.
110+
processQueue()
111+
brains = list(catalog.getAllBrains())
112+
assert len(brains) >= 9
113+
for brain in brains:
114+
if brain.portal_type == "Plone Site":
115+
continue
116+
# All created and modified dates should be in the previous year
117+
# (or earlier).
118+
assert not brain.created.isCurrentYear()
119+
assert not brain.modified.isCurrentYear()
120+
# Given what we see with getAllBrains, let's check the actual content
121+
# items as well.
122+
obj = brain.getObject()
123+
assert not obj.created().isCurrentYear()
124+
assert not obj.modified().isCurrentYear()
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from DateTime import DateTime
2+
from plone.exportimport import interfaces
3+
from plone.exportimport.importers import content
4+
from plone.exportimport.importers import final
5+
from zope.component import getAdapter
6+
7+
import pytest
8+
9+
10+
class TestImporterContent:
11+
@pytest.fixture(autouse=True)
12+
def _init(self, portal_multilingual_content):
13+
self.portal = portal_multilingual_content
14+
self.importer = final.FinalImporter(self.portal)
15+
16+
def test_adapter_is_registered(self):
17+
adapter = getAdapter(
18+
self.portal, interfaces.INamedImporter, "plone.importer.final"
19+
)
20+
assert isinstance(adapter, final.FinalImporter)
21+
22+
def test_output_is_str(self, multilingual_import_path):
23+
result = self.importer.import_data(base_path=multilingual_import_path)
24+
assert isinstance(result, str)
25+
assert result == "FinalImporter: Updated 19 objects"
26+
27+
def test_empty_import_path(self, empty_import_path):
28+
# The import path is ignored by this importer.
29+
result = self.importer.import_data(base_path=empty_import_path)
30+
assert isinstance(result, str)
31+
assert result == "FinalImporter: Updated 19 objects"
32+
33+
34+
class TestImporterDates:
35+
@pytest.fixture(autouse=True)
36+
def _init(self, portal, base_import_path, load_json):
37+
self.portal = portal
38+
content_importer = content.ContentImporter(self.portal)
39+
content_importer.import_data(base_path=base_import_path)
40+
importer = final.FinalImporter(portal)
41+
importer.import_data(base_path=base_import_path)
42+
43+
@pytest.mark.parametrize(
44+
"uid,method_name,value",
45+
[
46+
[
47+
"35661c9bb5be42c68f665aa1ed291418",
48+
"created",
49+
"2024-02-13T18:16:04+00:00",
50+
],
51+
[
52+
"35661c9bb5be42c68f665aa1ed291418",
53+
"modified",
54+
"2024-02-13T18:16:04+00:00",
55+
],
56+
[
57+
"3e0dd7c4b2714eafa1d6fc6a1493f953",
58+
"created",
59+
"2024-03-19T19:02:18+00:00",
60+
],
61+
[
62+
"3e0dd7c4b2714eafa1d6fc6a1493f953",
63+
"modified",
64+
"2024-03-19T19:02:18+00:00",
65+
],
66+
[
67+
"e7359727ace64e609b79c4091c38822a",
68+
"created",
69+
"2024-02-13T18:15:56+00:00",
70+
],
71+
# Note: this would fail without the final importer, because this
72+
# is a folder that gets modified later when a document is added.
73+
[
74+
"e7359727ace64e609b79c4091c38822a",
75+
"modified",
76+
"2024-02-13T20:51:06+00:00",
77+
],
78+
],
79+
)
80+
def test_date_is_set(self, uid, method_name, value):
81+
from plone.exportimport.utils.content import object_from_uid
82+
83+
content = object_from_uid(uid)
84+
assert getattr(content, method_name)() == DateTime(value)

0 commit comments

Comments
 (0)