From 6f536b59450652489168d5e9249377d5c6a004f3 Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 18 May 2022 13:27:27 +0200 Subject: [PATCH 1/3] Use the dexterity machinery in api.content.create Fixes #439 --- news/439.changed | 1 + src/plone/api/content.py | 51 ++++++++++++++++--------- src/plone/api/tests/test_content.py | 58 ++++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 19 deletions(-) create mode 100644 news/439.changed diff --git a/news/439.changed b/news/439.changed new file mode 100644 index 00000000..59df0a45 --- /dev/null +++ b/news/439.changed @@ -0,0 +1 @@ +When creating dexterity object with api.content.create use the dexterity machinery to not have the object renamed after it is initially created. diff --git a/src/plone/api/content.py b/src/plone/api/content.py index f0a9c850..0e0e635e 100644 --- a/src/plone/api/content.py +++ b/src/plone/api/content.py @@ -8,10 +8,15 @@ from plone.api.validation import required_parameters from plone.app.linkintegrity.exceptions import LinkIntegrityNotificationException from plone.app.uuid.utils import uuidToObject +from plone.dexterity.fti import DexterityFTI +from plone.dexterity.utils import addContentToContainer +from plone.dexterity.utils import createContent from plone.uuid.interfaces import IUUID from Products.CMFCore.DynamicType import DynamicType from Products.CMFCore.WorkflowCore import WorkflowException from zope.component import ComponentLookupError +from Products.CMFPlone.interfaces import IConstrainTypes +from zExceptions import BadRequest from zope.component import getMultiAdapter from zope.component import getSiteManager from zope.container.interfaces import INameChooser @@ -20,7 +25,6 @@ from zope.interface import providedBy import random -import transaction _marker = [] @@ -63,14 +67,39 @@ def create( :class:`~plone.api.exc.InvalidParameterError` :Example: :ref:`content-create-example` """ - # Create a temporary id if the id is not given - content_id = not safe_id and id or str(random.randint(0, 99999999)) + fti = portal.get_tool("portal_types").get(type) if title: kwargs["title"] = title try: - container.invokeFactory(type, content_id, **kwargs) + if isinstance(fti, DexterityFTI): + # For dexterity objects we want to not use the invokeFactory + # method because we want to have the id generated by the name chooser + constraints = IConstrainTypes(container, None) + if constraints and fti not in constraints.allowedContentTypes(): + raise ValueError( + f"Subobject type disallowed by IConstrainTypes adapter: {type}" + ) + if id: + kwargs["id"] = id + content = createContent(type, **kwargs) + + if not content.id: + content.id = INameChooser(container).chooseName(title, content) + if not safe_id: + content.id = INameChooser(container).chooseName( + content.id or title, content + ) + if id and id != content.id: + raise BadRequest( + "The id you provided conflicts with an existing object or it is reserved" + ) + addContentToContainer(container, content) + content_id = content.id + else: + content_id = not safe_id and id or str(random.randint(0, 99999999)) + container.invokeFactory(type, content_id, **kwargs) except UnicodeDecodeError: # UnicodeDecodeError is a subclass of ValueError, # so will be swallowed below unless we re-raise it here @@ -92,20 +121,6 @@ def create( ) content = container[content_id] - if not id or (safe_id and id): - # Create a new id from title - chooser = INameChooser(container) - derived_id = id or title - new_id = chooser.chooseName(derived_id, content) - # kacee: we must do a partial commit, else the renaming fails because - # the object isn't in the zodb. - # Thus if it is not in zodb, there's nothing to move. We should - # choose a correct id when - # the object is created. - # maurits: tests run fine without this though. - transaction.savepoint(optimistic=True) - content.aq_parent.manage_renameObject(content_id, new_id) - return content diff --git a/src/plone/api/tests/test_content.py b/src/plone/api/tests/test_content.py index 44109d95..d05b9178 100644 --- a/src/plone/api/tests/test_content.py +++ b/src/plone/api/tests/test_content.py @@ -1,6 +1,8 @@ """Tests for plone.api.content.""" from Acquisition import aq_base +from contextlib import AbstractContextManager +from gc import callbacks from OFS.CopySupport import CopyError from OFS.event import ObjectWillBeMovedEvent from OFS.interfaces import IObjectWillBeMovedEvent @@ -12,6 +14,7 @@ from plone.app.textfield import RichTextValue from plone.base.interfaces import INavigationRoot from plone.indexer import indexer +from plone.namedfile import NamedBlobFile from plone.uuid.interfaces import IMutableUUID from plone.uuid.interfaces import IUUIDGenerator from Products.CMFCore.WorkflowCore import WorkflowException @@ -21,6 +24,7 @@ from zope.component import getGlobalSiteManager from zope.component import getUtility from zope.container.contained import ContainerModifiedEvent +from zope.event import subscribers from zope.interface import alsoProvides from zope.lifecycleevent import IObjectModifiedEvent from zope.lifecycleevent import IObjectMovedEvent @@ -30,6 +34,21 @@ import unittest +class EventRecorder(AbstractContextManager): + def __init__(self): + self.events = [] + + def __enter__(self): + subscribers.append(self.record) + return self.events + + def record(self, event): + self.events.append(event.__class__.__name__) + + def __exit__(self, *exc_info): + subscribers.remove(self.record) + + class TestPloneApiContent(unittest.TestCase): """Unit tests for content manipulation using plone.api.""" @@ -66,7 +85,6 @@ def setUp(self): id="events", container=self.portal, ) - self.team = api.content.create( container=self.about, type="Document", @@ -252,6 +270,44 @@ def test_create_dexterity(self): ) self.verify_intids() + def test_create_dexterity_folder_events(self): + """Check the events that are fired when a folder is created. + Ensure the events fired are consistent whether or not an id is passed + and assert that the object is not moved in the creation process. + """ + container = self.portal + + # Create a folder passing an id and record the events + with EventRecorder() as events_id_yes: + foo = api.content.create( + id="foo", + container=container, + title="Bar", + type="Dexterity Folder", + ) + + self.assertEqual(foo.id, "foo") + + # Create a folder not passing an id and record the events + with EventRecorder() as events_id_not: + bar = api.content.create( + container=container, + title="Bar", + type="Dexterity Folder", + ) + + self.assertEqual(bar.id, "bar") + + self.assertListEqual( + events_id_yes, events_id_not, "The events fired should be consistent" + ) + + self.assertNotIn( + "ObjectMovedEvent", + events_id_yes, + "The object should be created in the proper place", + ) + def test_create_content(self): """Test create content.""" container = self.portal From 6a1f39b1f55a6a4c396967de82be3879eae7cff4 Mon Sep 17 00:00:00 2001 From: ale-rt Date: Wed, 18 May 2022 15:23:05 +0200 Subject: [PATCH 2/3] fixup! Use the dexterity machinery in api.content.create --- src/plone/api/content.py | 36 ++++++++++++++++++----------- src/plone/api/tests/test_content.py | 10 ++++++-- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/plone/api/content.py b/src/plone/api/content.py index 0e0e635e..8bb2de8e 100644 --- a/src/plone/api/content.py +++ b/src/plone/api/content.py @@ -9,7 +9,6 @@ from plone.app.linkintegrity.exceptions import LinkIntegrityNotificationException from plone.app.uuid.utils import uuidToObject from plone.dexterity.fti import DexterityFTI -from plone.dexterity.utils import addContentToContainer from plone.dexterity.utils import createContent from plone.uuid.interfaces import IUUID from Products.CMFCore.DynamicType import DynamicType @@ -76,6 +75,12 @@ def create( if isinstance(fti, DexterityFTI): # For dexterity objects we want to not use the invokeFactory # method because we want to have the id generated by the name chooser + if not fti.isConstructionAllowed(container): + raise ValueError(f"Cannot create {type}") + + container_fti = container.getTypeInfo() + if container_fti is not None and not container_fti.allowType(type): + raise ValueError(f"Disallowed subobject type: {type}") constraints = IConstrainTypes(container, None) if constraints and fti not in constraints.allowedContentTypes(): raise ValueError( @@ -85,18 +90,23 @@ def create( kwargs["id"] = id content = createContent(type, **kwargs) - if not content.id: - content.id = INameChooser(container).chooseName(title, content) - if not safe_id: - content.id = INameChooser(container).chooseName( - content.id or title, content - ) - if id and id != content.id: - raise BadRequest( - "The id you provided conflicts with an existing object or it is reserved" - ) - addContentToContainer(container, content) - content_id = content.id + name_chooser = INameChooser(container) + if content.id: + # Check that the id we picked is valid + if not name_chooser.checkName(content.id, content): + if safe_id: + content.id = INameChooser(container).chooseName( + content.id, content + ) + else: + raise BadRequest( + "The id you provided conflicts with " + "an existing object or it is reserved" + ) + else: + content.id = name_chooser.chooseName(title, content) + + content_id = container._setObject(content.id, content) else: content_id = not safe_id and id or str(random.randint(0, 99999999)) container.invokeFactory(type, content_id, **kwargs) diff --git a/src/plone/api/tests/test_content.py b/src/plone/api/tests/test_content.py index d05b9178..6de2e5e1 100644 --- a/src/plone/api/tests/test_content.py +++ b/src/plone/api/tests/test_content.py @@ -2,7 +2,6 @@ from Acquisition import aq_base from contextlib import AbstractContextManager -from gc import callbacks from OFS.CopySupport import CopyError from OFS.event import ObjectWillBeMovedEvent from OFS.interfaces import IObjectWillBeMovedEvent @@ -14,7 +13,6 @@ from plone.app.textfield import RichTextValue from plone.base.interfaces import INavigationRoot from plone.indexer import indexer -from plone.namedfile import NamedBlobFile from plone.uuid.interfaces import IMutableUUID from plone.uuid.interfaces import IUUIDGenerator from Products.CMFCore.WorkflowCore import WorkflowException @@ -381,6 +379,14 @@ def test_create_with_safe_id(self): self.assertEqual(second_page.id, "test-document-1") self.assertEqual(second_page.portal_type, "Document") + def test_create_with_not_lowercase_id(self): + obj = api.content.create( + container=self.portal, + type="Dexterity Item", + id="Doc1", + ) + self.assertEqual(obj.id, "Doc1") + def test_create_raises_unicodedecodeerror(self): """Test that the create method raises UnicodeDecodeErrors correctly.""" site = getGlobalSiteManager() From 787e54f1775c617cc6f4a09204423b2e7370532f Mon Sep 17 00:00:00 2001 From: ale-rt Date: Thu, 19 May 2022 10:44:31 +0200 Subject: [PATCH 3/3] Raise Unauthorized when the user has no permission to create the content --- src/plone/api/content.py | 5 +++-- src/plone/api/tests/test_content.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/plone/api/content.py b/src/plone/api/content.py index 8bb2de8e..0ddbf699 100644 --- a/src/plone/api/content.py +++ b/src/plone/api/content.py @@ -1,5 +1,6 @@ """Module that provides functionality for content manipulation.""" +from AccessControl import Unauthorized from copy import copy as _copy from plone.api import portal from plone.api.exc import InvalidParameterError @@ -13,9 +14,9 @@ from plone.uuid.interfaces import IUUID from Products.CMFCore.DynamicType import DynamicType from Products.CMFCore.WorkflowCore import WorkflowException -from zope.component import ComponentLookupError from Products.CMFPlone.interfaces import IConstrainTypes from zExceptions import BadRequest +from zope.component import ComponentLookupError from zope.component import getMultiAdapter from zope.component import getSiteManager from zope.container.interfaces import INameChooser @@ -76,7 +77,7 @@ def create( # For dexterity objects we want to not use the invokeFactory # method because we want to have the id generated by the name chooser if not fti.isConstructionAllowed(container): - raise ValueError(f"Cannot create {type}") + raise Unauthorized(f"Cannot create {type}") container_fti = container.getTypeInfo() if container_fti is not None and not container_fti.allowType(type): diff --git a/src/plone/api/tests/test_content.py b/src/plone/api/tests/test_content.py index 6de2e5e1..222092bd 100644 --- a/src/plone/api/tests/test_content.py +++ b/src/plone/api/tests/test_content.py @@ -387,6 +387,18 @@ def test_create_with_not_lowercase_id(self): ) self.assertEqual(obj.id, "Doc1") + def test_create_anonymous_unauthorized(self): + from AccessControl import Unauthorized + from plone.app.testing import logout + + logout() + with self.assertRaises(Unauthorized): + api.content.create( + container=self.portal, + type="Dexterity Item", + id="foo", + ) + def test_create_raises_unicodedecodeerror(self): """Test that the create method raises UnicodeDecodeErrors correctly.""" site = getGlobalSiteManager()