diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 73447527bc..72d7ada3ff 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -3,7 +3,7 @@ on: [push, pull_request]
jobs:
build:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
- runs-on: ubuntu-latest
+ runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
diff --git a/CHANGES.rst b/CHANGES.rst
index 252e57258f..ea84b6bbac 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -8,6 +8,58 @@ Changelog
.. towncrier release notes start
+9.9.0 (2024-12-18)
+------------------
+
+New features:
+
+
+- When a Link content item is linked by UID, resolve its URL as the linked target URL for anonymous users. @cekk (#1847)
+
+
+Bug fixes:
+
+
+- Fix resolving paths in deserializer if the target was moved in the same request. @cekk (#1848)
+- Make slate block linkintegrity checking more robust in case data isn't in the expected format. @cekk (#1849)
+- Optimized performance of DexterityObjectPrimaryFieldTarget adapter. @maurits (#1851)
+
+
+Internal:
+
+
+- Fix time-dependence of tests. @davisagli (#1850)
+
+
+9.8.5 (2024-11-25)
+------------------
+
+Bug fixes:
+
+
+- Fix log in after changing email when "email as login" is enabled
+ [erral] (#1835)
+- Fix tests after #1839 and plone.app.event#411
+ [erral] (#1844)
+- Do not change request during relation fields serialization
+ [cekk] (#1845)
+
+
+Internal:
+
+
+- Test that recurrence serialization provides correct data
+ [erral] (#1809)
+- Additional tests to login name changes
+ [erral] (#1840)
+
+
+Documentation:
+
+
+- `html_use_opensearch` value must not have a trailing slash. Clean up comments. @stevepiercy (#1846)
+
+
9.8.4 (2024-11-05)
------------------
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 5bc2f084a2..4282d26b9f 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -290,7 +290,7 @@ def patch_pygments_to_highlight_jsonschema():
# base URL from which the finished HTML is served.
# Announce that we have an opensearch plugin
# https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_use_opensearch
-html_use_opensearch = "https://plonerestapi.readthedocs.org/"
+html_use_opensearch = "https://plonerestapi.readthedocs.org"
# This is the file name suffix for HTML files (e.g. ".xhtml").
diff --git a/news/1835.bugfix b/news/1835.bugfix
deleted file mode 100644
index e8b78b308a..0000000000
--- a/news/1835.bugfix
+++ /dev/null
@@ -1,2 +0,0 @@
-Fix log in after changing email when "email as login" is enabled
-[erral]
diff --git a/setup.py b/setup.py
index 69b2ff7184..15942cd754 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@
import sys
-version = "9.8.5.dev0"
+version = "9.9.1.dev0"
if sys.version_info.major == 2:
raise ValueError(
diff --git a/src/plone/restapi/blocks_linkintegrity.py b/src/plone/restapi/blocks_linkintegrity.py
index 754f5c256b..dc5343ff1d 100644
--- a/src/plone/restapi/blocks_linkintegrity.py
+++ b/src/plone/restapi/blocks_linkintegrity.py
@@ -69,9 +69,8 @@ def __init__(self, context, request):
def __call__(self, block):
value = (block or {}).get(self.field, [])
children = iterate_children(value or [])
-
for child in children:
- node_type = child.get("type")
+ node_type = child.get("type", "")
if node_type:
handler = getattr(self, f"handle_{node_type}", None)
if handler:
diff --git a/src/plone/restapi/deserializer/blocks.py b/src/plone/restapi/deserializer/blocks.py
index 59e26a807c..e4a8f825be 100644
--- a/src/plone/restapi/deserializer/blocks.py
+++ b/src/plone/restapi/deserializer/blocks.py
@@ -24,9 +24,10 @@ def iterate_children(value):
queue = deque(value)
while queue:
child = queue.pop()
- yield child
- if child.get("children"):
- queue.extend(child["children"] or [])
+ if isinstance(child, dict):
+ yield child
+ if child.get("children", []):
+ queue.extend(child["children"] or [])
@implementer(IFieldDeserializer)
diff --git a/src/plone/restapi/deserializer/utils.py b/src/plone/restapi/deserializer/utils.py
index 67d67d5556..c6b687fa2f 100644
--- a/src/plone/restapi/deserializer/utils.py
+++ b/src/plone/restapi/deserializer/utils.py
@@ -2,6 +2,9 @@
from plone.uuid.interfaces import IUUID
from plone.uuid.interfaces import IUUIDAware
from zope.component import getMultiAdapter
+from plone.app.redirector.interfaces import IRedirectionStorage
+from zope.component import getUtility
+
import re
PATH_RE = re.compile(r"^(.*?)((?=/@@|#).*)?$")
@@ -35,6 +38,14 @@ def path2uid(context, link):
suffix = match.group(2) or ""
obj = portal.unrestrictedTraverse(path, None)
+ if obj is None:
+ # last try: maybe the object or some parent has been renamed.
+ # if yes, there should be a reference into redirection storage
+ storage = getUtility(IRedirectionStorage)
+ alias_path = storage.get(path)
+ if alias_path:
+ path = alias_path
+ obj = portal.unrestrictedTraverse(path, None)
if obj is None or obj == portal:
return link
segments = path.split("/")
diff --git a/src/plone/restapi/serializer/configure.zcml b/src/plone/restapi/serializer/configure.zcml
index 0e84f64f42..32c63b2d78 100644
--- a/src/plone/restapi/serializer/configure.zcml
+++ b/src/plone/restapi/serializer/configure.zcml
@@ -8,6 +8,8 @@
+
+
diff --git a/src/plone/restapi/serializer/dxcontent.py b/src/plone/restapi/serializer/dxcontent.py
index 1c546d091d..eb0fc44c6e 100644
--- a/src/plone/restapi/serializer/dxcontent.py
+++ b/src/plone/restapi/serializer/dxcontent.py
@@ -1,6 +1,7 @@
from AccessControl import getSecurityManager
from Acquisition import aq_inner
from Acquisition import aq_parent
+from plone.app.contenttypes.interfaces import ILink
from plone.autoform.interfaces import READ_PERMISSIONS_KEY
from plone.dexterity.interfaces import IDexterityContainer
from plone.dexterity.interfaces import IDexterityContent
@@ -219,23 +220,26 @@ def __init__(self, context, request):
def __call__(self):
primary_field_name = self.get_primary_field_name()
+ if not primary_field_name:
+ return
for schema in iterSchemata(self.context):
read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY)
- for name, field in getFields(schema).items():
- if not self.check_permission(read_permissions.get(name), self.context):
- continue
-
- if name != primary_field_name:
- continue
-
- target_adapter = queryMultiAdapter(
- (field, self.context, self.request), IPrimaryFieldTarget
- )
- if target_adapter:
- target = target_adapter()
- if target:
- return target
+ field = getFields(schema).get(primary_field_name)
+ if field is None:
+ continue
+ if not self.check_permission(
+ read_permissions.get(primary_field_name),
+ self.context,
+ ):
+ return
+
+ target_adapter = queryMultiAdapter(
+ (field, self.context, self.request), IPrimaryFieldTarget
+ )
+ if not target_adapter:
+ return
+ return target_adapter()
def get_primary_field_name(self):
fieldname = None
@@ -266,3 +270,27 @@ def check_permission(self, permission_name, obj):
sm.checkPermission(permission.title, obj)
)
return self.permission_cache[permission_name]
+
+
+@adapter(ILink, Interface)
+@implementer(IObjectPrimaryFieldTarget)
+class LinkObjectPrimaryFieldTarget:
+ def __init__(self, context, request):
+ self.context = context
+ self.request = request
+
+ self.permission_cache = {}
+
+ def __call__(self):
+ """
+ If user can edit Link object, do not return remoteUrl
+ """
+ pm = getToolByName(self.context, "portal_membership")
+ if bool(pm.isAnonymousUser()):
+ for schema in iterSchemata(self.context):
+ for name, field in getFields(schema).items():
+ if name == "remoteUrl":
+ serializer = queryMultiAdapter(
+ (field, self.context, self.request), IFieldSerializer
+ )
+ return serializer()
diff --git a/src/plone/restapi/serializer/relationfield.py b/src/plone/restapi/serializer/relationfield.py
index d54cadb9d1..3115568d0e 100644
--- a/src/plone/restapi/serializer/relationfield.py
+++ b/src/plone/restapi/serializer/relationfield.py
@@ -18,7 +18,7 @@
@implementer(IJsonCompatible)
def relationvalue_converter(value):
if value.to_object:
- request = getRequest()
+ request = getRequest().clone()
request.form["metadata_fields"] = ["UID"]
summary = getMultiAdapter((value.to_object, request), ISerializeToJsonSummary)()
return json_compatible(summary)
diff --git a/src/plone/restapi/services/users/update.py b/src/plone/restapi/services/users/update.py
index 3d7d3b724b..e2fdb19604 100644
--- a/src/plone/restapi/services/users/update.py
+++ b/src/plone/restapi/services/users/update.py
@@ -107,7 +107,20 @@ def reply(self):
if security.use_email_as_login and "email" in user_settings_to_update:
value = user_settings_to_update["email"]
pas = getToolByName(self.context, "acl_users")
- pas.updateLoginName(user.getId(), value)
+
+ try:
+ pas.updateLoginName(user.getId(), value)
+ except ValueError:
+ return self._error(
+ 400,
+ "Bad request",
+ _(
+ "Cannot update login name of user to '${new_email}'.",
+ mapping={
+ "new_email": value,
+ },
+ ),
+ )
roles = user_settings_to_update.get("roles", {})
if roles:
@@ -149,7 +162,17 @@ def reply(self):
if security.use_email_as_login and "email" in user_settings_to_update:
value = user_settings_to_update["email"]
- set_own_login_name(user, value)
+ try:
+ set_own_login_name(user, value)
+ except ValueError:
+ return self._error(
+ 400,
+ "Bad request",
+ _(
+ "Cannot update login name of user to '${new_email}'.",
+ mapping={"new_email": value},
+ ),
+ )
else:
if self._is_anonymous:
diff --git a/src/plone/restapi/tests/test_blocks_deserializer.py b/src/plone/restapi/tests/test_blocks_deserializer.py
index 874e5446e8..dcaf3bf2c8 100644
--- a/src/plone/restapi/tests/test_blocks_deserializer.py
+++ b/src/plone/restapi/tests/test_blocks_deserializer.py
@@ -1,3 +1,4 @@
+from plone import api
from plone.dexterity.interfaces import IDexterityFTI
from plone.dexterity.interfaces import IDexterityItem
from plone.restapi.behaviors import IBlocks
@@ -724,3 +725,16 @@ def test_deserialize_url_with_image_scales(self):
res = self.deserialize(blocks=blocks)
self.assertTrue(res.blocks["123"]["url"].startswith("../resolveuid/"))
self.assertNotIn("image_scales", res.blocks["123"])
+
+ def test_deserializer_resolve_path_also_if_it_is_an_alias(self):
+
+ self.portal.invokeFactory(
+ "Document",
+ id="doc",
+ )
+ api.content.move(source=self.portal.doc, id="renamed-doc")
+ blocks = {"abc": {"href": "%s/doc" % self.portal.absolute_url()}}
+
+ res = self.deserialize(blocks=blocks)
+ link = res.blocks["abc"]["href"]
+ self.assertEqual(link, f"../resolveuid/{self.portal['renamed-doc'].UID()}")
diff --git a/src/plone/restapi/tests/test_dxcontent_serializer.py b/src/plone/restapi/tests/test_dxcontent_serializer.py
index 567aa01e62..1989181472 100644
--- a/src/plone/restapi/tests/test_dxcontent_serializer.py
+++ b/src/plone/restapi/tests/test_dxcontent_serializer.py
@@ -16,11 +16,13 @@
from plone.namedfile.file import NamedFile
from plone.registry.interfaces import IRegistry
from plone.restapi.interfaces import IExpandableElement
+from plone.restapi.interfaces import IObjectPrimaryFieldTarget
from plone.restapi.interfaces import ISerializeToJson
from plone.restapi.testing import PLONE_RESTAPI_DX_INTEGRATION_TESTING
from plone.restapi.tests.test_expansion import ExpandableElementFoo
from plone.restapi.serializer.utils import get_portal_type_title
from plone.uuid.interfaces import IMutableUUID
+from plone.uuid.interfaces import IUUID
from Products.CMFCore.utils import getToolByName
from zope.component import getGlobalSiteManager
from zope.component import getMultiAdapter
@@ -756,3 +758,38 @@ def test_primary_field_target_with_edit_permissions(self):
serializer = getMultiAdapter((self.portal.doc1, self.request), ISerializeToJson)
data = serializer()
self.assertNotIn("targetUrl", data)
+
+ def test_primary_field_target_for_link_objects_for_auth_return_none(self):
+ self.portal.invokeFactory(
+ "Document",
+ id="linked",
+ )
+ self.portal.invokeFactory(
+ "Link",
+ id="link",
+ remoteUrl=f"../resolveuid/{IUUID(self.portal.linked)}",
+ )
+ wftool = getToolByName(self.portal, "portal_workflow")
+ wftool.doActionFor(self.portal.linked, "publish")
+ adapter = getMultiAdapter(
+ (self.portal.link, self.request), IObjectPrimaryFieldTarget
+ )
+ self.assertEqual(adapter(), None)
+
+ def test_primary_field_target_for_link_objects_for_anonymous(self):
+ self.portal.invokeFactory(
+ "Document",
+ id="linked",
+ )
+ self.portal.invokeFactory(
+ "Link",
+ id="link",
+ remoteUrl=f"../resolveuid/{IUUID(self.portal.linked)}",
+ )
+ wftool = getToolByName(self.portal, "portal_workflow")
+ wftool.doActionFor(self.portal.linked, "publish")
+ logout()
+ adapter = getMultiAdapter(
+ (self.portal.link, self.request), IObjectPrimaryFieldTarget
+ )
+ self.assertEqual(adapter(), self.portal.linked.absolute_url())
diff --git a/src/plone/restapi/tests/test_dxfield_serializer.py b/src/plone/restapi/tests/test_dxfield_serializer.py
index bac16ffcc3..625ea83d57 100644
--- a/src/plone/restapi/tests/test_dxfield_serializer.py
+++ b/src/plone/restapi/tests/test_dxfield_serializer.py
@@ -325,6 +325,19 @@ def test_relationlist_field_serialization_returns_list(self):
value,
)
+ def test_relation_field_serialization_do_not_change_request(self):
+ self.request.form["metadata_fields"] = ["foo", "bar"]
+ doc2 = self.portal[
+ self.portal.invokeFactory(
+ "DXTestDocument",
+ id="doc2",
+ title="Referenceable Document",
+ description="Description 2",
+ )
+ ]
+ self.serialize("test_relationchoice_field", doc2)
+ self.assertEqual(self.request.form["metadata_fields"], ["foo", "bar"])
+
def test_remoteurl_field_in_links_get_converted(self):
link = self.portal[
self.portal.invokeFactory(
diff --git a/src/plone/restapi/tests/test_serializer_summary.py b/src/plone/restapi/tests/test_serializer_summary.py
index 16b54f7b0c..e186c2f31c 100644
--- a/src/plone/restapi/tests/test_serializer_summary.py
+++ b/src/plone/restapi/tests/test_serializer_summary.py
@@ -1,19 +1,32 @@
+from datetime import datetime
+from datetime import timedelta
from DateTime import DateTime
from plone.app.contentlisting.interfaces import IContentListingObject
+from plone.app.event.dx.traverser import OccurrenceTraverser
from plone.app.testing import popGlobalRegistry
from plone.app.testing import pushGlobalRegistry
from plone.dexterity.utils import createContentInContainer
+from plone.event.interfaces import IEvent
+from plone.event.interfaces import IEventRecurrence
from plone.restapi.interfaces import ISerializeToJsonSummary
from plone.restapi.testing import PLONE_RESTAPI_DX_INTEGRATION_TESTING
from plone.restapi.testing import register_static_uuid_utility
from Products.CMFCore.utils import getToolByName
from zope.component import getMultiAdapter
from zope.component.hooks import getSite
+from zope.interface import alsoProvides
import Missing
+import pytz
import unittest
+try:
+ from plone.app.event.adapters import OccurrenceContentListingObject
+except ImportError:
+ OccurrenceContentListingObject = None
+
+
class TestSummarySerializers(unittest.TestCase):
layer = PLONE_RESTAPI_DX_INTEGRATION_TESTING
@@ -203,3 +216,69 @@ def test_dx_type_summary(self):
},
summary,
)
+
+
+class TestSummarySerializerswithRecurrenceObjects(unittest.TestCase):
+ layer = PLONE_RESTAPI_DX_INTEGRATION_TESTING
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+
+ pushGlobalRegistry(getSite())
+ register_static_uuid_utility(prefix="c6dcbd55ab2746e199cd4ed458")
+
+ behaviors = self.portal.portal_types.DXTestDocument.behaviors
+ behaviors = behaviors + (
+ "plone.eventbasic",
+ "plone.eventrecurrence",
+ )
+ self.portal.portal_types.DXTestDocument.behaviors = behaviors
+
+ self.start = datetime(1995, 7, 31, 13, 45, tzinfo=pytz.timezone("UTC"))
+ self.event = createContentInContainer(
+ self.portal,
+ "DXTestDocument",
+ id="doc1",
+ title="Lorem Ipsum event",
+ description="Description event",
+ start=self.start,
+ end=self.start + timedelta(hours=1),
+ recurrence="RRULE:FREQ=DAILY;COUNT=3", # see https://github.com/plone/plone.app.event/blob/master/plone/app/event/tests/base_setup.py
+ )
+
+ alsoProvides(self.event, IEvent)
+ alsoProvides(self.event, IEventRecurrence)
+
+ def tearDown(self):
+ popGlobalRegistry(getSite())
+
+ @unittest.skipIf(
+ OccurrenceContentListingObject is not None,
+ "this test needs a plone.app.event version that does not include a IContentListingObject adapter",
+ )
+ def test_dx_event_with_recurrence_old_version(self):
+ tomorrow = self.start + timedelta(days=1)
+ tomorrow_str = tomorrow.strftime("%Y-%m-%d")
+ ot = OccurrenceTraverser(self.event, self.request)
+ ocurrence = ot.publishTraverse(self.request, tomorrow_str)
+
+ with self.assertRaises(TypeError):
+ getMultiAdapter((ocurrence, self.request), ISerializeToJsonSummary)()
+
+ @unittest.skipIf(
+ OccurrenceContentListingObject is None,
+ "this test needs a plone.app.event version that includes a IContentListingObject adapter",
+ )
+ def test_dx_event_with_recurrence_new_version(self):
+ tomorrow = self.start + timedelta(days=1)
+ tomorrow_str = tomorrow.strftime("%Y-%m-%d")
+ ot = OccurrenceTraverser(self.event, self.request)
+ ocurrence = ot.publishTraverse(self.request, tomorrow_str)
+ self.request.form["metadata_fields"] = ["start"]
+ summary = getMultiAdapter((ocurrence, self.request), ISerializeToJsonSummary)()
+ self.assertEqual(
+ datetime.fromisoformat(summary["start"]).date().isoformat(),
+ tomorrow.date().isoformat(),
+ )
+ self.assertEqual(summary["title"], ocurrence.Title())
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index 3d80320769..2352f62aff 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -1663,3 +1663,139 @@ def test_user_changes_email_when_login_with_email_and_uuid_userids(self):
},
)
self.assertTrue(new_login_with_new_email_response.ok)
+
+ def test_manager_changes_email_to_existing_when_login_with_email(self):
+ """test that when login with email is enabled and a manager tries to change a user's email
+ to a previously existing one
+ """
+ # enable use_email_as_login
+ security_settings = getAdapter(self.portal, ISecuritySchema)
+ security_settings.use_email_as_login = True
+ transaction.commit()
+
+ # Create user 1
+ response = self.api_session.post(
+ "/@users",
+ json={
+ "email": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(response.ok)
+ userid = response.json()["id"]
+
+ # Create user 2
+ response = self.api_session.post(
+ "/@users",
+ json={
+ "email": "second@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(response.ok)
+
+ transaction.commit()
+
+ # Log in
+ anon_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(anon_response.ok)
+
+ # try to change the email to an existing one, it should fail
+ email_change_response = self.api_session.patch(
+ f"/@users/{userid}",
+ json={
+ "email": "second@example.com",
+ },
+ )
+ self.assertFalse(email_change_response.ok)
+ self.assertEqual(email_change_response.status_code, 400)
+ email_change_response_json = email_change_response.json()
+ self.assertEqual(
+ email_change_response_json.get("error", {}).get("message"),
+ "Cannot update login name of user to 'second@example.com'.",
+ )
+
+ # Email was not changed, so log in with the old one
+ new_login_with_old_email_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(new_login_with_old_email_response.ok)
+
+ def test_user_changes_email_to_existing_one_when_login_with_email(self):
+ """test that when login with email is enabled and the user changes their email
+ they can log in with the new email
+ """
+ # enable use_email_as_login
+ security_settings = getAdapter(self.portal, ISecuritySchema)
+ security_settings.use_email_as_login = True
+ transaction.commit()
+
+ # Create user 1
+ response = self.api_session.post(
+ "/@users",
+ json={
+ "email": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(response.ok)
+ userid = response.json()["id"]
+
+ # Create user 2
+ response = self.api_session.post(
+ "/@users",
+ json={
+ "email": "second@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(response.ok)
+ transaction.commit()
+
+ # log in with email
+ anon_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(anon_response.ok)
+ auth_token = anon_response.json().get("token")
+
+ user_api_session = RelativeSession(self.portal_url, test=self)
+ user_api_session.headers.update({"Accept": "application/json"})
+ user_api_session.headers.update({"Authorization": f"Bearer {auth_token}"})
+
+ # try to change e-mail to an existing one, it should fail
+ email_change_response = user_api_session.patch(
+ f"/@users/{userid}",
+ json={"email": "second@example.com"},
+ )
+
+ self.assertEqual(email_change_response.status_code, 400)
+ email_change_response_json = email_change_response.json()
+ self.assertEqual(
+ email_change_response_json.get("error", {}).get("message"),
+ "Cannot update login name of user to 'second@example.com'.",
+ )
+
+ # email was not changed, so log in with the old one
+ new_login_with_old_email_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(new_login_with_old_email_response.ok)