documentation".
+html_title = "%(project)s v%(release)s" % {"project": project, "release": release}
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
html_theme_options = {
+ "extra_footer": """The text and illustrations in this website are licensed by the Plone Foundation under a Creative Commons Attribution 4.0 International license. Plone and the Plone® logo are registered trademarks of the Plone Foundation, registered in the United States and other countries. For guidelines on the permitted uses of the Plone trademarks, see https://plone.org/foundation/logo. All other trademarks are owned by their respective owners.
+ Pull request previews by Read the Docs.
""",
+ "footer_end": ["version.html"],
+ "icon_links": [
+ {
+ "name": "GitHub",
+ "url": "https://github.com/plone/plone.restapi",
+ "icon": "fa-brands fa-square-github",
+ "type": "fontawesome",
+ "attributes": {
+ "target": "_blank",
+ "rel": "noopener me",
+ "class": "nav-link custom-fancy-css"
+ }
+ },
+ {
+ "name": "Mastodon",
+ "url": "https://plone.social/@plone",
+ "icon": "fa-brands fa-mastodon",
+ "type": "fontawesome",
+ "attributes": {
+ "target": "_blank",
+ "rel": "noopener me",
+ "class": "nav-link custom-fancy-css"
+ }
+ },
+ {
+ "name": "Twitter",
+ "url": "https://twitter.com/plone",
+ "icon": "fa-brands fa-square-twitter",
+ "type": "fontawesome",
+ "attributes": {
+ "target": "_blank",
+ "rel": "noopener me",
+ "class": "nav-link custom-fancy-css"
+ }
+ },
+ ],
+ "logo": {
+ "text": html_title,
+ },
+ "navigation_with_keys": True,
"path_to_docs": "docs",
- "repository_url": "https://github.com/plone/plone.restapi",
"repository_branch": "master",
- "use_repository_button": True,
- "use_issues_button": True,
+ "repository_url": "https://github.com/plone/plone.restapi",
+ "search_bar_text": "Search", # TODO: Confirm usage of search_bar_text
"use_edit_page_button": True,
- "extra_footer": """The text and illustrations in this website are licensed by the Plone Foundation under a Creative Commons Attribution 4.0 International license. Plone and the Plone® logo are registered trademarks of the Plone Foundation, registered in the United States and other countries. For guidelines on the permitted uses of the Plone trademarks, see https://plone.org/foundation/logo. All other trademarks are owned by their respective owners.
""",
+ "use_issues_button": True,
+ "use_repository_button": True,
}
-# Theme options are theme-specific and customize the look and feel of a theme
-# further. For a list of options available for each theme, see the
-# documentation.
-# html_theme_options = {}
-
# Add any paths that contain custom themes here, relative to this directory.
# html_theme_path = []
-# The name for this set of Sphinx documents. If None, it defaults to
-# " v documentation".
-html_title = "%(project)s v%(release)s" % {"project": project, "release": release}
-
# A shorter title for the navigation bar. Default is the same as html_title.
# html_short_title = None
-# The name of an image file (relative to this directory) to place at the top
-# of the sidebar.
-# html_logo = None
-
-# The name of an image file (within the static path) to use as favicon of the
-# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
-# pixels large.
-# html_favicon = None
-
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
@@ -274,12 +311,14 @@ def patch_pygments_to_highlight_jsonschema():
# For more information see:
# https://myst-parser.readthedocs.io/en/latest/syntax/optional.html
myst_enable_extensions = [
- "deflist", # You will be able to utilise definition lists
+ "deflist", # Support definition lists.
# https://myst-parser.readthedocs.io/en/latest/syntax/optional.html#definition-lists
- "linkify", # Identify “bare” web URLs and add hyperlinks.
+ "linkify", # Identify "bare" web URLs and add hyperlinks.
"colon_fence", # You can also use ::: delimiters to denote code fences,\
# instead of ```.
- "substitution", # https://myst-parser.readthedocs.io/en/latest/syntax/optional.html#substitutions-with-jinja2
+ "substitution", # plone.restapi \
+ # https://myst-parser.readthedocs.io/en/latest/syntax/optional.html#substitutions-with-jinja2
+ "html_image", # For inline images. See https://myst-parser.readthedocs.io/en/latest/syntax/optional.html#html-images
]
myst_substitutions = {
diff --git a/docs/source/contributing/index.md b/docs/source/contributing/index.md
index 1c0c9ac617..f442e71306 100644
--- a/docs/source/contributing/index.md
+++ b/docs/source/contributing/index.md
@@ -13,9 +13,9 @@ This section describes how to contribute to the `plone.restapi` project.
It extends {doc}`plone:contributing/index`.
-## Pre-requisites
+## Prerequisites
-Prepare your system by installing {ref}`plone:plone-pre-requisites-label`.
+Prepare your system by installing {ref}`plone:plone-prerequisites-label`.
## Set up development environment
diff --git a/docs/source/endpoints/aliases.md b/docs/source/endpoints/aliases.md
index 46f68342e1..da841c2e0a 100644
--- a/docs/source/endpoints/aliases.md
+++ b/docs/source/endpoints/aliases.md
@@ -70,9 +70,10 @@ Response:
:language: http
```
-## Adding URL aliases in bulk
+## Adding URL aliases in bulk via JSON
-You can add multiple URL aliases for multiple pages by sending a `POST` request to the `/@aliases` endpoint on site `root`. **datetime** parameter is optional:
+You can add multiple URL aliases for multiple pages by sending a `POST` request to the `/@aliases` endpoint on site `root` using a JSON payload.
+**datetime** parameter is optional:
```{eval-rst}
.. http:example:: curl httpie python-requests
@@ -85,10 +86,26 @@ Response:
:language: http
```
+## Adding URL aliases in bulk via CSV
-## Listing all available aliases
+You can add multiple URL aliases for multiple pages by sending a `POST` request to the `/@aliases` endpoint on site `root` using a CSV file.
+**datetime** parameter is optional:
-To list all aliases, send a `GET` request to the `/@aliases` endpoint on site `root`:
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.req
+```
+
+Response:
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.resp
+:language: http
+```
+
+
+## Listing all available aliases via JSON
+
+To list all aliases in JSON format, send a `GET` request to the `/@aliases` endpoint on site `root`:
```{eval-rst}
.. http:example:: curl httpie python-requests
@@ -101,6 +118,21 @@ Response:
:language: http
```
+## Listing all available aliases via CSV
+
+To download all aliases as a CSV file, send a `GET` request to the `/@aliases` endpoint on site `root`:
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.req
+```
+
+Response:
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.resp
+:language: http
+```
+
## Filter aliases
To search for specific aliases, send a `GET` request to the `/@aliases` endpoint on site `root` with a `q` parameter:
diff --git a/news/1808.bugfix b/news/1808.bugfix
deleted file mode 100644
index 7a8ab1df8e..0000000000
--- a/news/1808.bugfix
+++ /dev/null
@@ -1,2 +0,0 @@
-Fix error getting allow_discussion value when p.a.discussion is not activated.
-[maurits]
diff --git a/news/1835.bugfix b/news/1835.bugfix
new file mode 100644
index 0000000000..e8b78b308a
--- /dev/null
+++ b/news/1835.bugfix
@@ -0,0 +1,2 @@
+Fix log in after changing email when "email as login" is enabled
+[erral]
diff --git a/requirements-docs.txt b/requirements-docs.txt
index a522f65309..4b3918f397 100644
--- a/requirements-docs.txt
+++ b/requirements-docs.txt
@@ -1,20 +1,10 @@
-docutils<0.17,>=0.15 # sphinx-book-theme 0.2.0 has requirement docutils<0.17,>=0.15
-Sphinx<5,>=3 # sphinx-book-theme 0.3.3 has requirement sphinx<5,>=3
-sphinxcontrib-applehelp==1.0.4 # newer versions require sphinx 5
-sphinxcontrib-devhelp==1.0.2 # newer versions require sphinx 5
-sphinxcontrib-htmlhelp==2.0.1 # newer versions require sphinx 5
-sphinxcontrib-qthelp==1.0.3 # newer versions require sphinx 5
-sphinxcontrib-serializinghtml==1.1.5 # newer versions require sphinx 5
-jsx-lexer
-lesscpy
linkify-it-py
myst-parser
+plone-sphinx-theme
+setuptools # required by sphinxcontrib.httpexample, remove when it migrates to importlib
sphinx-autobuild
-sphinx-book-theme
sphinx-copybutton
-sphinx-sitemap
-sphinx-togglebutton
sphinxcontrib.httpdomain
sphinxcontrib.httpexample
-sphinxcontrib-spelling
sphinxext-opengraph
+vale==2.30.0
diff --git a/setup.py b/setup.py
index c9b76e9107..69b2ff7184 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@
import sys
-version = "9.7.3.dev0"
+version = "9.8.5.dev0"
if sys.version_info.major == 2:
raise ValueError(
diff --git a/src/plone/restapi/configure.zcml b/src/plone/restapi/configure.zcml
index 2f79ec0c7a..8bd6c38c33 100644
--- a/src/plone/restapi/configure.zcml
+++ b/src/plone/restapi/configure.zcml
@@ -22,6 +22,7 @@
+
+
+
@@ -17,7 +26,7 @@
accept="application/json,application/schema+json"
factory=".get.AliasesGet"
for="Products.CMFCore.interfaces.IContentish"
- permission="zope2.View"
+ permission="Products.CMFPlone.ManageContextAliases"
name="@aliases"
/>
@@ -25,7 +34,7 @@
method="POST"
factory=".add.AliasesPost"
for="*"
- permission="cmf.ModifyPortalContent"
+ permission="Products.CMFPlone.ManageContextAliases"
name="@aliases"
/>
@@ -33,7 +42,7 @@
method="POST"
factory=".add.AliasesRootPost"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
- permission="cmf.ModifyPortalContent"
+ permission="Products.CMFPlone.ManagePortalAliases"
name="@aliases"
/>
@@ -41,7 +50,7 @@
method="DELETE"
factory=".delete.AliasesDelete"
for="*"
- permission="cmf.ModifyPortalContent"
+ permission="Products.CMFPlone.ManageContextAliases"
name="@aliases"
/>
@@ -49,7 +58,7 @@
method="DELETE"
factory=".delete.AliasesDelete"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
- permission="cmf.ModifyPortalContent"
+ permission="Products.CMFPlone.ManagePortalAliases"
name="@aliases"
/>
diff --git a/src/plone/restapi/services/aliases/get.py b/src/plone/restapi/services/aliases/get.py
index c1fcf0b6ec..3ac3f81210 100644
--- a/src/plone/restapi/services/aliases/get.py
+++ b/src/plone/restapi/services/aliases/get.py
@@ -9,6 +9,7 @@
from zope.component.hooks import getSite
from zope.interface import implementer
from zope.interface import Interface
+import json
@implementer(IExpandableElement)
@@ -26,7 +27,8 @@ def reply_item(self):
redirects = storage.redirects(context_path)
aliases = [deroot_path(alias) for alias in redirects]
self.request.response.setStatus(201)
- return [{"path": alias} for alias in aliases]
+ self.request.response.setHeader("Content-Type", "application/json")
+ return [{"path": alias} for alias in aliases], len(aliases)
def reply_root(self):
"""
@@ -48,24 +50,54 @@ def reply_root(self):
newbatch = RedirectsControlPanel(self.context, self.request).redirects()
items_total = len([item for item in newbatch])
+ self.request.response.setHeader("Content-Type", "application/json")
+
return redirects, items_total
+ def reply_root_csv(self):
+ batch = RedirectsControlPanel(self.context, self.request).redirects()
+ redirects = [entry for entry in batch]
+
+ for redirect in redirects:
+ del redirect["redirect"]
+ redirect["datetime"] = datetimelike_to_iso(redirect["datetime"])
+ self.request.response.setStatus(201)
+
+ self.request.form["b_start"] = "0"
+ self.request.form["b_size"] = "1000000"
+ self.request.__annotations__.pop("plone.memoize")
+
+ filestream = RedirectsControlPanel(self.context, self.request).download()
+ content = filestream.read()
+ filestream.close()
+
+ self.request.response.setHeader("Content-Type", "text/csv")
+ self.request.response.setHeader(
+ "Content-Disposition", "attachment; filename=redirects.csv"
+ )
+ self.request.response.setHeader("Content-Length", str(len(content)))
+ return content
+
def __call__(self, expand=False):
result = {"aliases": {"@id": f"{self.context.absolute_url()}/@aliases"}}
if not expand:
return result
-
if IPloneSiteRoot.providedBy(self.context):
- items, items_total = self.reply_root()
- result["aliases"]["items"] = items
- result["aliases"]["items_total"] = items_total
+ if self.request.getHeader("Accept") == "text/csv":
+ result["aliases"]["items"] = self.reply_root_csv()
+ return result
+ else:
+ items, items_total = self.reply_root()
else:
- result["aliases"]["items"] = self.reply_item()
- result["aliases"]["items_total"] = len(result["aliases"]["items"])
-
+ items, items_total = self.reply_item()
+ result["aliases"]["items"] = items
+ result["aliases"]["items_total"] = items_total
return result
+_no_content_marker = object()
+
+
class AliasesGet(Service):
"""Get aliases"""
@@ -73,6 +105,16 @@ def reply(self):
aliases = Aliases(self.context, self.request)
return aliases(expand=True)["aliases"]
+ def render(self):
+ self.check_permission()
+ content = self.reply()
+ if self.request.getHeader("Accept") == "text/csv":
+ return content["items"]
+ if content is not _no_content_marker:
+ return json.dumps(
+ content, indent=2, sort_keys=True, separators=(", ", ": ")
+ )
+
def deroot_path(path):
"""Remove the portal root from alias"""
diff --git a/src/plone/restapi/services/navigation/get.py b/src/plone/restapi/services/navigation/get.py
index 665f22da01..a21a52abe4 100644
--- a/src/plone/restapi/services/navigation/get.py
+++ b/src/plone/restapi/services/navigation/get.py
@@ -135,7 +135,9 @@ def navtree(self):
if brain_parent_path == navtree_path:
# This should be already provided by the portal_tabs_view
continue
- if brain.exclude_from_nav and not context_path.startswith(brain_path):
+ if brain.exclude_from_nav and not f"{brain_path}/".startswith(
+ f"{context_path}/"
+ ):
# skip excluded items if they're not in our context path
continue
url = brain.getURL()
diff --git a/src/plone/restapi/services/site/get.py b/src/plone/restapi/services/site/get.py
index 43840a8b87..4ab0a9801e 100644
--- a/src/plone/restapi/services/site/get.py
+++ b/src/plone/restapi/services/site/get.py
@@ -10,6 +10,7 @@
from Products.CMFPlone.interfaces import IImagingSchema
from Products.CMFPlone.interfaces import ISiteSchema
from Products.CMFPlone.utils import getSiteLogo
+from Products.CMFPlone.controlpanel.browser.redirects import RedirectionSet
from zope.component import adapter
from zope.component import getMultiAdapter
from zope.component import getUtility
@@ -49,6 +50,7 @@ def __call__(self, expand=False):
"plone.default_language": language_settings.default_language,
"plone.available_languages": language_settings.available_languages,
"plone.portal_timezone": self.plone_timezone(),
+ "features": self.features(),
}
)
@@ -73,6 +75,18 @@ def plone_timezone(self):
return portal_timezone
+ def features(self):
+ """Indicates which features are supported by this site.
+
+ This can be used by a client to check for version-dependent features.
+ """
+ result = {
+ "filter_aliases_by_date": hasattr(
+ RedirectionSet, "supports_date_range_filtering"
+ ),
+ }
+ return result
+
class SiteGet(Service):
def reply(self):
diff --git a/src/plone/restapi/services/users/update.py b/src/plone/restapi/services/users/update.py
index 428727d1c3..3d7d3b724b 100644
--- a/src/plone/restapi/services/users/update.py
+++ b/src/plone/restapi/services/users/update.py
@@ -104,6 +104,11 @@ def reply(self):
self.set_member_portrait(user, value)
user.setMemberProperties(mapping={key: value}, force_empty=True)
+ 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)
+
roles = user_settings_to_update.get("roles", {})
if roles:
to_add = [key for key, enabled in roles.items() if enabled]
@@ -142,6 +147,10 @@ def reply(self):
self.set_member_portrait(user, value)
user.setMemberProperties(mapping={key: value}, force_empty=True)
+ 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)
+
else:
if self._is_anonymous:
return self._error(
diff --git a/src/plone/restapi/tests/dxtypes.py b/src/plone/restapi/tests/dxtypes.py
index b3632d41d9..c7f8ef148f 100644
--- a/src/plone/restapi/tests/dxtypes.py
+++ b/src/plone/restapi/tests/dxtypes.py
@@ -312,6 +312,9 @@ class ITestBehavior(model.Schema):
test_behavior_field = schema.TextLine(required=False)
# Add nav_title to test if it gets substituted in Navigation service
nav_title = schema.TextLine(required=False)
+ # Add a field with read permission set
+ test_secure_field = schema.TextLine(required=False)
+ read_permission(test_secure_field="cmf.ManagePortal")
@provider(IFormFieldProvider)
diff --git a/src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.req b/src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.req
new file mode 100644
index 0000000000..fc049dcc0f
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.req
@@ -0,0 +1,13 @@
+POST /plone/@aliases HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
+
+------WebKitFormBoundary7MA4YWxkTrZu0gW
+Content-Disposition: form-data; name="file"; filename="test_file.csv"
+Content-Type: text/csv
+
+old path,new path,datetime,manual
+/old-page,/front-page,2022/01/01 00:00:00 GMT+0,True
+
+------WebKitFormBoundary7MA4YWxkTrZu0gW--
diff --git a/src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.resp b/src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.req b/src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.req
new file mode 100644
index 0000000000..be32ef6582
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.req
@@ -0,0 +1,3 @@
+GET /plone/@aliases HTTP/1.1
+Accept: text/csv
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.resp b/src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.resp
new file mode 100644
index 0000000000..04246f1761
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.resp
@@ -0,0 +1,6 @@
+HTTP/1.1 201 Created
+Content-Type: text/csv; charset=utf-8
+
+old path,new path,datetime,manual
+/fizzbuzz,/front-page,2022/05/05 00:00:00 GMT+0,True
+/old-page,/front-page,2022/05/05 00:00:00 GMT+0,True
diff --git a/src/plone/restapi/tests/http-examples/site_get.resp b/src/plone/restapi/tests/http-examples/site_get.resp
index 28a4c11670..43a502cc1e 100644
--- a/src/plone/restapi/tests/http-examples/site_get.resp
+++ b/src/plone/restapi/tests/http-examples/site_get.resp
@@ -3,6 +3,9 @@ Content-Type: application/json
{
"@id": "http://localhost:55001/plone/@site",
+ "features": {
+ "filter_aliases_by_date": false
+ },
"plone.allowed_sizes": [
"huge 1600:65536",
"great 1200:65536",
diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py
index 69d031339a..aaed01c1fd 100644
--- a/src/plone/restapi/tests/test_documentation.py
+++ b/src/plone/restapi/tests/test_documentation.py
@@ -1,6 +1,7 @@
from base64 import b64encode
from datetime import datetime
from datetime import timezone
+import io
from pkg_resources import resource_filename
from plone import api
from plone.app.discussion.interfaces import ICommentAddedEvent
@@ -2135,6 +2136,58 @@ def test_aliases_root_get(self):
response = self.api_session.get(url + query)
save_request_and_response_for_docs("aliases_root_get", response)
+ def test_aliases_root_get_csv_format(self):
+ url = f"{self.portal.absolute_url()}/@aliases"
+ query = ""
+
+ payload = {
+ "items": [
+ {
+ "path": "/old-page",
+ "redirect-to": "/front-page",
+ "datetime": "2022-05-05",
+ },
+ {
+ "path": "/fizzbuzz",
+ "redirect-to": "/front-page",
+ "datetime": "2022-05-05",
+ },
+ ]
+ }
+ response = self.api_session.post(url, json=payload)
+ self.api_session.headers.update({"Content-Type": "application/json"})
+ self.api_session.headers.update({"Accept": "text/csv"})
+ response = self.api_session.get(url + query)
+ save_request_and_response_for_docs("aliases_root_get_csv_format", response)
+
+ def test_aliases_root_add_csv_format(self):
+ url = f"{self.portal.absolute_url()}/@aliases"
+
+ content = b"old path,new path,datetime,manual\n/old-page,/front-page,2022/01/01 00:00:00 GMT+0,True\n"
+ csv_file = io.BytesIO(content)
+ csv_file.name = "test_file.csv"
+
+ # Setting a fixed boundary intentionally to make the producing .req and .resp files deterministic
+ boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
+
+ # Manually construct the multipart body
+ body = (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file"; filename="{csv_file.name}"\r\n'
+ "Content-Type: text/csv\r\n\r\n"
+ f"{content.decode()}\r\n"
+ f"--{boundary}--\r\n"
+ )
+
+ headers = {
+ "Accept": "application/json",
+ "Authorization": "Basic YWRtaW46c2VjcmV0",
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
+ }
+
+ response = self.api_session.post(url, headers=headers, data=body)
+ save_request_and_response_for_docs("aliases_root_add_csv_format", response)
+
def test_aliases_root_filter(self):
# Get aliases
url = f"{self.portal.absolute_url()}/@aliases"
diff --git a/src/plone/restapi/tests/test_dxcontent_serializer.py b/src/plone/restapi/tests/test_dxcontent_serializer.py
index 6fd514956f..567aa01e62 100644
--- a/src/plone/restapi/tests/test_dxcontent_serializer.py
+++ b/src/plone/restapi/tests/test_dxcontent_serializer.py
@@ -27,6 +27,10 @@
from zope.component import provideAdapter
from zope.component import queryUtility
from zope.interface import Interface
+from z3c.relationfield import RelationValue
+from z3c.relationfield.event import _setRelation
+from zope.component import getUtility
+from zope.intid.interfaces import IIntIds
from zope.publisher.interfaces.browser import IBrowserRequest
from importlib import import_module
@@ -191,6 +195,30 @@ def test_serializer_includes_expansion(self):
"foo",
)
+ def test_serializer_excludes_deleted_relations(self):
+
+ intids = getUtility(IIntIds)
+ self.portal.invokeFactory(
+ "DXTestDocument",
+ id="doc2",
+ )
+ rel1 = RelationValue(intids.getId(self.portal.doc1))
+ rel2 = RelationValue(intids.getId(self.portal.doc2))
+ self.portal.doc1.test_relationlist_field = [
+ rel1,
+ rel2,
+ ]
+ _setRelation(self.portal.doc1, "test_relationlist_field", rel1)
+ _setRelation(self.portal.doc1, "test_relationlist_field", rel2)
+ # delete doc2 to make sure we have a None value in the relation list
+ self.portal.manage_delObjects(["doc2"])
+
+ obj = self.serialize()
+ self.assertEqual(1, len(obj["test_relationlist_field"]))
+ self.assertEqual(
+ "http://nohost/plone/doc1", obj["test_relationlist_field"][0]["@id"]
+ )
+
def test_get_is_folderish(self):
obj = self.serialize()
self.assertIn("is_folderish", obj)
diff --git a/src/plone/restapi/tests/test_search.py b/src/plone/restapi/tests/test_search.py
index e4ddb4c38d..84b6e0b480 100644
--- a/src/plone/restapi/tests/test_search.py
+++ b/src/plone/restapi/tests/test_search.py
@@ -151,6 +151,29 @@ def test_search_on_context_constrains_query_by_path(self):
set(result_paths(response.json())),
)
+ def test_search_with_parentheses(self):
+ query = {"SearchableText": "("}
+ response = self.api_session.get("/@search", params=query)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.json(), [], "Expected no items for query with only parentheses"
+ )
+
+ query = {"SearchableText": ")"}
+ response = self.api_session.get("/@search", params=query)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.json(), [], "Expected no items for query with only parentheses"
+ )
+
+ query = {"SearchableText": "lorem(ipsum)"}
+ response = self.api_session.get("/@search", params=query)
+ self.assertEqual(response.status_code, 200)
+ items = [item["title"] for item in response.json().get("items", [])]
+ self.assertIn(
+ "Lorem Ipsum", items, "Expected 'Lorem Ipsum' to be found in search results"
+ )
+
def test_search_in_vhm(self):
# Install a Virtual Host Monster
if "virtual_hosting" not in self.app.objectIds():
diff --git a/src/plone/restapi/tests/test_services_aliases.py b/src/plone/restapi/tests/test_services_aliases.py
new file mode 100644
index 0000000000..27e4ca693e
--- /dev/null
+++ b/src/plone/restapi/tests/test_services_aliases.py
@@ -0,0 +1,162 @@
+from plone.app.testing import SITE_OWNER_PASSWORD
+from plone.app.testing import SITE_OWNER_NAME
+from plone.app.testing import TEST_USER_ID
+from plone.app.testing import setRoles
+from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
+from plone.restapi.testing import RelativeSession
+
+import transaction
+import unittest
+
+
+class TestAliases(unittest.TestCase):
+
+ layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
+
+ def setUp(self):
+ self.app = self.layer["app"]
+ self.portal = self.layer["portal"]
+ self.portal_url = self.portal.absolute_url()
+ setRoles(self.portal, TEST_USER_ID, ["Manager"])
+
+ self.api_session = RelativeSession(self.portal_url, test=self)
+ self.api_session.headers.update({"Accept": "application/json"})
+ self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD)
+
+ self.portal.invokeFactory("Document", id="front-page")
+ transaction.commit()
+
+ def tearDown(self):
+ self.api_session.close()
+
+ def test_alias_non_root(self):
+ data = {
+ "items": [
+ {
+ "path": "/alias-with-date",
+ "redirect-to": "/front-page",
+ "datetime": "2024-09-17T12:00:00",
+ }
+ ]
+ }
+ response = self.api_session.post("/front-page/@aliases", json=data)
+ self.assertEqual(response.status_code, 204)
+
+ # Verify alias exists
+ response = self.api_session.get("/front-page/@aliases")
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(len(response.json()["items"]), 1)
+
+ def test_alias_add_invalid_datetime(self):
+ """Test POST /@aliases with an invalid datetime, makes invalid date=None"""
+ data = {
+ "items": [
+ {
+ "path": "/alias-with-valid-date",
+ "redirect-to": "/front-page",
+ "datetime": "2024-09-17T12:00:00",
+ },
+ {
+ "path": "/alias-with-invalid-date",
+ "redirect-to": "/front-page",
+ "datetime": "invalid-date",
+ },
+ ]
+ }
+ response = self.api_session.post("/@aliases", json=data)
+ self.assertEqual(response.status_code, 204)
+ response = self.api_session.get("/@aliases")
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(len(response.json()["items"]), 2)
+
+ def test_alias_add_invalid_path(self):
+ """Test POST /@aliases with an invalid path"""
+
+ data = {"items": [{"path": "/valid-path", "redirect-to": "invalid-redirect"}]}
+ response = self.api_session.post("/@aliases", json=data)
+ self.assertEqual(response.status_code, 400)
+ response = self.api_session.get("/@aliases")
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(len(response.json()["items"]), 0)
+
+ def test_duplicate_alias(self):
+ data = {
+ "items": [
+ {"path": "/duplicate-alias", "redirect-to": "/front-page"},
+ {"path": "/duplicate-alias", "redirect-to": "/front-page"},
+ ]
+ }
+ self.api_session.post("/@aliases", json=data)
+ response = self.api_session.post("/@aliases", json=data)
+ self.assertEqual(response.status_code, 400)
+
+ def test_alias_without_redirect(self):
+ data = {"items": [{"path": "/alias-without-redirect"}]}
+ response = self.api_session.post("/@aliases", json=data)
+ self.assertEqual(response.status_code, 400)
+
+ def test_alias_csv_upload(self):
+ """Test POST /@aliases for CSV upload"""
+
+ content = b"old path,new path,datetime,manual\n/old-page,/front-page,2022/01/01 00:00:00 GMT+0,True\n"
+
+ response = self.api_session.post(
+ "/@aliases",
+ files={"file": ("aliases.csv", content, "text/csv")},
+ )
+
+ self.assertEqual(response.status_code, 204)
+ self.assertEqual(response.content, b"")
+ response = self.api_session.get("/@aliases")
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(
+ response.json().get("items"),
+ [
+ {
+ "datetime": "2022-01-01T00:00:00+00:00",
+ "manual": True,
+ "path": "/old-page",
+ "redirect-to": "/front-page",
+ }
+ ],
+ )
+
+ def test_alias_csv_download(self):
+ """Test GET /@aliases with CSV output"""
+
+ data = {
+ "items": [
+ {
+ "path": "/alias-page",
+ "redirect-to": "/front-page",
+ "datetime": "2022/01/01 00:00:00 GMT+0",
+ },
+ ]
+ }
+ self.api_session.post("/@aliases", json=data)
+ headers = {"Accept": "text/csv"}
+ response = self.api_session.get("/@aliases", headers=headers)
+ self.assertEqual(response.status_code, 201)
+ self.assertIn("Content-Disposition", response.headers)
+ self.assertEqual(response.headers["Content-Type"], "text/csv; charset=utf-8")
+ content = b"old path,new path,datetime,manual\r\n/alias-page,/front-page,2022/01/01 00:00:00 GMT+0,True\r\n"
+ self.assertEqual(content, response.content)
+
+ def test_alias_delete(self):
+ data = {"items": [{"path": "/alias-to-delete", "redirect-to": "/front-page"}]}
+ self.api_session.post("/@aliases", json=data)
+ response = self.api_session.delete(
+ "/@aliases",
+ json={
+ "items": [
+ {
+ "path": "/alias-to-delete",
+ }
+ ]
+ },
+ )
+ self.assertEqual(response.status_code, 204)
+
+ response = self.api_session.get("/@aliases")
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(len(response.json()["items"]), 0)
diff --git a/src/plone/restapi/tests/test_services_navigation.py b/src/plone/restapi/tests/test_services_navigation.py
index dc466bc38e..890c5c1220 100644
--- a/src/plone/restapi/tests/test_services_navigation.py
+++ b/src/plone/restapi/tests/test_services_navigation.py
@@ -124,6 +124,43 @@ def test_dont_broke_with_contents_without_review_state(self):
)
self.assertIsNone(response.json()["items"][1]["items"][3]["review_state"])
+ def test_show_excluded_items(self):
+ registry = getUtility(IRegistry)
+ settings = registry.forInterface(INavigationSchema, prefix="plone")
+
+ # Plone 5.2 and Plone 6.0 have different default values:
+ # False for Plone 6.0 and True for Plone 5.2
+ # explicitly set the value to False to avoid test failures
+ settings.show_excluded_items = False
+ createContentInContainer(
+ self.folder,
+ "Folder",
+ id="excluded-subfolder",
+ title="Excluded SubFolder",
+ exclude_from_nav=True,
+ )
+ transaction.commit()
+ response = self.api_session.get(
+ "/folder/@navigation", params={"expand.navigation.depth": 2}
+ )
+ self.assertNotIn(
+ "Excluded SubFolder",
+ [item["title"] for item in response.json()["items"][1]["items"]],
+ )
+
+ # change setting to show excluded items
+ registry = getUtility(IRegistry)
+ settings = registry.forInterface(INavigationSchema, prefix="plone")
+ settings.show_excluded_items = True
+ transaction.commit()
+ response = self.api_session.get(
+ "/folder/@navigation", params={"expand.navigation.depth": 2}
+ )
+ self.assertIn(
+ "Excluded SubFolder",
+ [item["title"] for item in response.json()["items"][1]["items"]],
+ )
+
def test_navigation_sorting(self):
registry = getUtility(IRegistry)
registry["plone.displayed_types"] = (
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index b203743b3e..3d80320769 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -1436,3 +1436,230 @@ def test_siteadm_not_change_manager_email(self):
self.assertEqual(
"manager@example.com", api.user.get(userid="manager").getProperty("email")
)
+
+ def test_manager_changes_email_when_login_with_email(self):
+ """test that when login with email is enabled and a manager changes a user's 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 a user
+ response = self.api_session.post(
+ "/@users",
+ json={
+ "email": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(response.ok)
+ userid = response.json()["id"]
+
+ transaction.commit()
+ anon_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(anon_response.ok)
+
+ email_change_response = self.api_session.patch(
+ f"/@users/{userid}",
+ json={
+ "email": "new_email@example.com",
+ },
+ )
+ self.assertTrue(email_change_response.ok)
+ new_login_with_old_email_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertFalse(new_login_with_old_email_response.ok)
+ new_login_with_new_email_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "new_email@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(new_login_with_new_email_response.ok)
+
+ def test_user_changes_email_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 a user
+ response = self.api_session.post(
+ "/@users",
+ json={
+ "email": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(response.ok)
+ userid = response.json()["id"]
+
+ transaction.commit()
+ 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}"})
+
+ email_change_response = user_api_session.patch(
+ f"/@users/{userid}",
+ json={"email": "new_email@example.com"},
+ )
+
+ self.assertTrue(email_change_response.ok)
+ new_login_with_old_email_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertFalse(new_login_with_old_email_response.ok)
+ new_login_with_new_email_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "new_email@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(new_login_with_new_email_response.ok)
+
+ def test_manager_changes_email_when_login_with_email_and_uuid_userids(self):
+ """test that when login with email is enabled and a manager changes a user's email
+ they can log in with the new email.
+
+ The site is configured to save userids as uuid
+
+ """
+ # enable use_email_as_login
+ security_settings = getAdapter(self.portal, ISecuritySchema)
+ security_settings.use_email_as_login = True
+ security_settings.use_uuid_as_userid = True
+ transaction.commit()
+ # Create a user
+ response = self.api_session.post(
+ "/@users",
+ json={
+ "email": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(response.ok)
+ userid = response.json()["id"]
+ transaction.commit()
+ anon_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(anon_response.ok)
+
+ email_change_response = self.api_session.patch(
+ f"/@users/{userid}",
+ json={
+ "email": "new_email@example.com",
+ },
+ )
+ self.assertTrue(email_change_response.ok)
+ new_login_with_old_email_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertFalse(new_login_with_old_email_response.ok)
+ new_login_with_new_email_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "new_email@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(new_login_with_new_email_response.ok)
+
+ def test_user_changes_email_when_login_with_email_and_uuid_userids(self):
+ """test that when login with email is enabled and the user changes their email
+ they can log in with the new email
+
+ The site is configured to save userids as uuid
+
+ """
+ # enable use_email_as_login
+ security_settings = getAdapter(self.portal, ISecuritySchema)
+ security_settings.use_email_as_login = True
+ security_settings.use_uuid_as_userid = True
+
+ transaction.commit()
+ # Create a user
+ response = self.api_session.post(
+ "/@users",
+ json={
+ "email": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(response.ok)
+ userid = response.json()["id"]
+ transaction.commit()
+ 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}"})
+
+ email_change_response = user_api_session.patch(
+ f"/@users/{userid}",
+ json={"email": "new_email@example.com"},
+ )
+
+ self.assertTrue(email_change_response.ok)
+ new_login_with_old_email_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "howard.zinn@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertFalse(new_login_with_old_email_response.ok)
+ new_login_with_new_email_response = self.anon_api_session.post(
+ "/@login",
+ json={
+ "login": "new_email@example.com",
+ "password": TEST_USER_PASSWORD,
+ },
+ )
+ self.assertTrue(new_login_with_new_email_response.ok)
diff --git a/src/plone/restapi/tests/test_site_serializer.py b/src/plone/restapi/tests/test_site_serializer.py
index 34174a7629..60de5ffffd 100644
--- a/src/plone/restapi/tests/test_site_serializer.py
+++ b/src/plone/restapi/tests/test_site_serializer.py
@@ -38,6 +38,7 @@ def setUp(self):
if fti is not None:
behavior_list = [a for a in fti.behaviors]
behavior_list.append("volto.blocks")
+ behavior_list.append("tests.restapi.test_behavior")
fti.behaviors = tuple(behavior_list)
# Invalidating the cache is required for the FTI to be applied
# on the existing object