From 65931e288371f92296422972ebe115db90ca2281 Mon Sep 17 00:00:00 2001 From: bhardwajparth51 <196071556+bhardwajparth51@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:10:16 +0530 Subject: [PATCH] Fix IObjectCreatedEvent firing with temporary ID (#1362) Move notify(ObjectCreatedEvent) inside add() so it fires after INameChooser assigns the final ID, not before. Fixes the bug where subscribers could not rely on event.object.id for ID-dependent logic such as setting sequential shortnames. Fixes: https://github.com/plone/plone.restapi/issues/1362 --- news/1362.bugfix | 1 + src/plone/restapi/services/content/add.py | 8 ++-- src/plone/restapi/services/content/tus.py | 7 ++-- src/plone/restapi/services/content/utils.py | 7 +++- src/plone/restapi/tests/test_content_post.py | 42 +++++++++++++++++++ src/plone/restapi/tests/test_content_utils.py | 5 +-- 6 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 news/1362.bugfix diff --git a/news/1362.bugfix b/news/1362.bugfix new file mode 100644 index 0000000000..ec8bd8983a --- /dev/null +++ b/news/1362.bugfix @@ -0,0 +1 @@ +Fix `IObjectCreatedEvent` firing with a temporary object ID when content is created via the REST API. The event now fires after `INameChooser` assigns the final ID, so subscribers can rely on `event.object.id` to set sequential shortnames or perform ID-dependent logic. @bhardwajparth51 diff --git a/src/plone/restapi/services/content/add.py b/src/plone/restapi/services/content/add.py index b6e7633a48..19bb208fdd 100644 --- a/src/plone/restapi/services/content/add.py +++ b/src/plone/restapi/services/content/add.py @@ -16,9 +16,7 @@ from zExceptions import Unauthorized from zope.component import getMultiAdapter from zope.component import queryMultiAdapter -from zope.event import notify from zope.interface import alsoProvides -from zope.lifecycleevent import ObjectCreatedEvent import plone.protect.interfaces @@ -92,9 +90,9 @@ def reply(self): setattr(obj, "_plone.uuid", uid) if not getattr(deserializer, "notifies_create", False): - notify(ObjectCreatedEvent(obj)) - - obj = add(self.context, obj, rename=not bool(id_)) + obj = add(self.context, obj, rename=not bool(id_), notify_created=True) + else: + obj = add(self.context, obj, rename=not bool(id_)) # Link translation given the translation_of property if ( diff --git a/src/plone/restapi/services/content/tus.py b/src/plone/restapi/services/content/tus.py index 3f14e0b94e..15c6b21a98 100644 --- a/src/plone/restapi/services/content/tus.py +++ b/src/plone/restapi/services/content/tus.py @@ -17,9 +17,7 @@ from uuid import uuid4 from zExceptions import Unauthorized from zope.component import queryMultiAdapter -from zope.event import notify from zope.interface import implementer -from zope.lifecycleevent import ObjectCreatedEvent from zope.publisher.interfaces import IPublishTraverse from zope.publisher.interfaces import NotFound @@ -281,8 +279,9 @@ def create_or_modify_content(self, tus_upload): if mode == "create": if not getattr(deserializer, "notifies_create", False): - notify(ObjectCreatedEvent(obj)) - obj = add(self.context, obj) + obj = add(self.context, obj, notify_created=True) + else: + obj = add(self.context, obj) tus_upload.close() tus_upload.cleanup() diff --git a/src/plone/restapi/services/content/utils.py b/src/plone/restapi/services/content/utils.py index a2d958990a..b039960ef1 100644 --- a/src/plone/restapi/services/content/utils.py +++ b/src/plone/restapi/services/content/utils.py @@ -13,6 +13,7 @@ from zope.container.contained import ObjectAddedEvent from zope.container.interfaces import INameChooser from zope.event import notify +from zope.lifecycleevent import ObjectCreatedEvent def create(container, type_, id_=None, title=None): @@ -68,13 +69,15 @@ def create(container, type_, id_=None, title=None): return obj -def add(container, obj, rename=True): +def add(container, obj, rename=True, notify_created=False): """Add an object to a container.""" id_ = getattr(aq_base(obj), "id", None) # Archetypes objects are already created in a container thus we just fire # the notification events and rename the object if necessary. if base_hasattr(obj, "_at_rename_after_creation"): + if notify_created: + notify(ObjectCreatedEvent(obj)) notify(ObjectAddedEvent(obj, container, id_)) notifyContainerModified(container) if obj._at_rename_after_creation and rename: @@ -92,6 +95,8 @@ def add(container, obj, rename=True): suggestion = obj.Title() id_ = chooser.chooseName(suggestion, obj) obj.id = id_ + if notify_created: + notify(ObjectCreatedEvent(obj)) new_id = container._setObject(id_, obj) # _setObject triggers ObjectAddedEvent which can end up triggering a # content rule to move the item to a different container. In this case diff --git a/src/plone/restapi/tests/test_content_post.py b/src/plone/restapi/tests/test_content_post.py index 66b3315692..2bd458fe2f 100644 --- a/src/plone/restapi/tests/test_content_post.py +++ b/src/plone/restapi/tests/test_content_post.py @@ -211,6 +211,48 @@ def record_event(event): sm.unregisterHandler(record_event, (IObjectAddedEvent,)) sm.unregisterHandler(record_event, (IObjectModifiedEvent,)) + def test_post_to_folder_object_created_event_has_final_id(self): + """IObjectCreatedEvent must fire with the final, INameChooser-resolved ID. + + When no 'id' is supplied, plone.restapi assigns a temporary ID and + renames the object via INameChooser before adding it to the container. + Subscribers must see the final ID (e.g. 'my-sequential-doc') on + event.object.id, not the temporary one (e.g. 'document.2026-03-06.1234'). + + Regression test for https://github.com/plone/plone.restapi/issues/1362 + """ + sm = getGlobalSiteManager() + created_event_ids = [] + + def capture_id(event): + created_event_ids.append(event.object.id) + + sm.registerHandler(capture_id, (IObjectCreatedEvent,)) + + response = requests.post( + self.portal.folder1.absolute_url(), + headers={"Accept": "application/json"}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={"@type": "Document", "title": "My Sequential Doc"}, + ) + self.assertEqual(201, response.status_code) + + final_id = response.json()["id"] + self.assertEqual( + 1, len(created_event_ids), "Expected exactly one ObjectCreatedEvent" + ) + + event_id = created_event_ids[0] + self.assertEqual( + final_id, + event_id, + f"IObjectCreatedEvent carried '{event_id}' but final ID was '{final_id}'. " + "Subscribers relying on event.object.id will see a stale value. " + "See https://github.com/plone/plone.restapi/issues/1362", + ) + + sm.unregisterHandler(capture_id, (IObjectCreatedEvent,)) + def test_post_to_folder_with_apostrophe_dont_return_500(self): response = requests.post( self.portal.folder1.absolute_url(), diff --git a/src/plone/restapi/tests/test_content_utils.py b/src/plone/restapi/tests/test_content_utils.py index c9911335cf..dd894ea925 100644 --- a/src/plone/restapi/tests/test_content_utils.py +++ b/src/plone/restapi/tests/test_content_utils.py @@ -5,8 +5,6 @@ from plone.restapi.testing import PLONE_RESTAPI_DX_INTEGRATION_TESTING from zExceptions import Unauthorized from zope.component import getGlobalSiteManager -from zope.event import notify -from zope.lifecycleevent import ObjectCreatedEvent from zope.lifecycleevent.interfaces import IObjectAddedEvent import unittest @@ -94,8 +92,7 @@ def move_object(event): try: obj = create(self.folder, "Document", "my-document") - notify(ObjectCreatedEvent(obj)) - obj = add(self.folder, obj) + obj = add(self.folder, obj, notify_created=True) self.assertEqual(aq_parent(obj), self.portal) finally: sm.unregisterHandler(move_object, (IObjectAddedEvent,))