diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b354a9e4d5..73447527bc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,7 +50,7 @@ jobs: - run: pip install virtualenv - run: pip install wheel - name: pip install - run: pip install -r requirements-${{ matrix.plone-version }}.txt -r requirements-docs.txt + run: pip install -r requirements-${{ matrix.plone-version }}.txt # buildout - name: buildout diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 0bf1b9ba85..043663c1c4 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -18,7 +18,7 @@ build: # If there are no changes (git diff exits with 0) we force the command to return with 183. # This is a special exit code on Read the Docs that will cancel the build immediately. - | - if [ "$READTHEDOCS_VERSION_TYPE" = "external" ] && git diff --quiet origin/main -- docs/ .readthedocs.yaml requirements-docs.txt requirements.txt; + if [ "$READTHEDOCS_VERSION_TYPE" = "external" ] && git diff --quiet origin/main -- docs/ src/plone/restapi/tests/http-examples/ .readthedocs.yaml requirements-docs.txt requirements.txt; then exit 183; fi diff --git a/CHANGES.rst b/CHANGES.rst index 7d5323edb8..252e57258f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,70 @@ Changelog .. towncrier release notes start +9.8.4 (2024-11-05) +------------------ + +Bug fixes: + + +- URL Management control panel: Fix error handling in CSV upload. @davisagli (#1837) + + +9.8.3 (2024-11-01) +------------------ + +Bug fixes: + + +- Fixed Plone Site serialization when there is a field with read_permission set. @ericof (#1830) + + +9.8.2 (2024-10-30) +------------------ + +Bug fixes: + + +- `@search` service: Remove parentheses from search query. @tedw87 (#1828) + + +9.8.1 (2024-10-23) +------------------ + +Bug fixes: + + +- Fix `ComponentLookupError` for `Products.CMFPlone.ManagePortalAliases` permission, which could happen depending on package load order. @davisagli (#1827) + + +9.8.0 (2024-10-23) +------------------ + +New features: + + +- Added create and fetch aliases in CSV format. @Faakhir30 (#1812) +- Site service: Indicate whether the site supports filtering URL aliases by date. @davisagli (#1826) + + +Bug fixes: + + +- Fix error getting allow_discussion value when p.a.discussion is not activated. + [maurits] (#1808) +- Fix incorrect condition for ``show_excluded_items`` setting in the ``@navigation`` API. + [mamico] (#1816) +- Fix response of `RelationListFieldSerializer` by filtering out invalid items. @Faakhir30 (#1818) +- Aliases endpoint: Use "Manage Portal Aliases" and "Manage Content Aliases" permissions. @jackahl (#1820) + + +Documentation: + + +- Use Plone Sphinx Theme for documentation. Build docs when there are changes to http-examples. @stevepiercy (#1815) +- Fixed spelling of prerequisites. @stevepiercy (#1822) + + 9.7.2 (2024-09-05) ------------------ diff --git a/Makefile b/Makefile index 71c500930e..2e931a857b 100644 --- a/Makefile +++ b/Makefile @@ -95,10 +95,13 @@ black: ## Black zpretty: ## zpretty if [ -f "bin/zpretty" ]; then zpretty -i ./**/*.zcml; fi +.PHONY: python-clean +python-clean: ## Clean Python virtual environment + rm -rf bin include lib + .PHONY: docs-clean -docs-clean: ## Clean current and legacy docs build directories, and Python virtual environment +docs-clean: ## Clean current and legacy docs build directories cd $(DOCS_DIR) && rm -rf $(BUILDDIR)/ - rm -rf bin include lib rm -rf docs/build .PHONY: docs-html diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css deleted file mode 100644 index 09d09e359b..0000000000 --- a/docs/source/_static/custom.css +++ /dev/null @@ -1,353 +0,0 @@ -:root { - /* Add Font Awesome 5 icon and color for `todo` */ - --pst-icon-clipboard-list: '\f46d'; - --pst-icon-admonition-todo: var(--pst-icon-clipboard-list); - --pst-color-admonition-todo: 161 , 46, 233; - --target-color: #b9ee9e; - --codeblock-color: #aad993; -} - -.visuallyhidden { - display: none; -} -pre { - border-radius: 0; - background-color: white; - box-shadow: none; -} -a, -a:visited, -main.bd-content #main-content a, -main.bd-content #main-content a:visited { - color: #2980b9; -} -a:hover, -main.bd-content #main-content a:hover { - color: #1a567e; - text-decoration: none; -} -ul { - list-style-type: square; -} -ul li > p { - margin-bottom: 0.3rem; -} -ol li > p { - margin-bottom: 0.3rem; -} -img{ - margin: 1rem 0; -} -figure img, -.figure img { - box-shadow: 0 6px 24px 0 rgba(153,153,153,0.3); -} -.sidebar img.logo { - box-shadow: none; - width: 200px; - margin-bottom: 1rem; -} -span.linenos { - padding-right: 1em; -} -p.ploneorglink img { - vertical-align: bottom; -} -dt:target, -span.highlighted, -ul.search li span.highlighted { - background-color: var(--target-color); -} - -.bd-sidebar .nav ul { - padding: 0 0 0 1rem; -} -.bd-sidebar .nav .toctree-checkbox ~ label i { - transform: rotate(90deg); -} -.bd-sidebar .nav .toctree-checkbox:checked ~ label i { - transform: rotate(0deg); -} - -.toctree-wrapper .caption { - font-weight: bold; - font-size: 1.2em; - margin-top: 3rem; -} -.toctree-wrapper ul { - list-style: none; -} - -section:not(#glossary) h1 ~ dl { - display: grid; - grid-template-columns: max-content auto; -} -section:not(#glossary) h1 ~ dl dd { - margin-bottom: unset !important; -} - -div.section { - margin-bottom: 5rem; -} - -/* admonitions */ -.admonition { - border-radius: 0; - border: none; - border-left: .2rem solid; - border-left-color: rgba(var(--pst-color-admonition-default),1); -} -.admonition .admonition-title { - margin-bottom: 1.5rem !important; -} -.admonition.toggle .admonition-title { - cursor: pointer; - display: flex; -} -.admonition.toggle .admonition-title::after { - content: "\f105"; - font-weight: 900; - font-family: "Font Awesome 5 Free"; - margin-left: auto; -} -.admonition.toggle .admonition-title.open::after { - content: "\f107"; -} -/* admonition `todo` */ -.admonition.admonition-todo, -div.admonition.admonition-todo { - border-color: rgba(var(--pst-color-admonition-todo),1); -} -.admonition.admonition-todo > .admonition-title, -div.admonition.admonition-todo > .admonition-title { - background-color: rgba(var(--pst-color-admonition-todo),.1); -} -.admonition.admonition-todo > .admonition-title::before, -div.admonition.admonition-todo > .admonition-title::before { - color: rgba(var(--pst-color-admonition-todo),1); - content: var(--pst-icon-admonition-todo); -} -.admonition-github-only.admonition { - display: none; -} - - -.topic { - padding: 1.5em 1em .5em 1em; -} -.topic-title { - font-weight: bold; -} - - -/* Bootstrap */ -.btn-primary { - color: #fff; - background-color: #2980b9; - border-color: #2980b9; -} -.btn-primary { - background-color: #1f86ca; - border-color: #2980b9; -} - -/* Search */ - -/* Show search form. It is hidden by default. */ -#search-documentation, -#search-documentation~form, -#search-documentation~p { - display:block; -} -ul.search { - margin-left: 0; -} -p.search-summary { - margin: 1em 0 2rem 0; -} -#search-results ul { - list-style-type: none; - padding-left: 0; -} -#search-results ul li, -ul.search li { - margin-bottom: 2rem; - padding: 0; - background-image: none; - border-bottom: none; -} -#search-results ul li h3 { - margin: 0.4rem 0 .5rem; - font-size: 1.5rem; -} -#search-results ul li .breadcrumbs { - display: flex; - flex-direction: row; - flex-wrap: wrap; -} -#search-results ul li .breadcrumbs a { - font-weight: normal; -} -#search-results ul li .breadcrumbs .lastbreadcrumb { - white-space: nowrap; - display: inline-block; - max-width: 12rem; - overflow: hidden; /* "overflow"-Wert darf nicht "visible" sein */ - - text-overflow: ellipsis; -} -ul.search li p.context { - margin-left: 0; -} -.pathseparator { - padding: 0 0.7rem; -} - - -/* submenu */ -.bd-toc { - box-shadow: 0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1); -} - -/* extra sidebar */ -div.sidebar:not(.margin){ - width: 40%; - float: right; - clear: right; - margin: .3rem 0 .3rem 0.5em; - padding: 2rem 0 1.5rem 1rem !important; - background-color: rgba(var(--pst-color-admonition-note),.1); - border: none; - border-left: 8px rgba(var(--pst-color-admonition-default),1) solid; - border-radius: .2rem; - box-shadow: 0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1); -} - -div.sidebar:not(.margin) .figure { - margin-top: 0; - padding-top: 0; - margin-left: 0; - padding-left: 0; -} -div.sidebar:not(.margin) img.logo { - margin-top: 0; - margin-bottom: .3rem; -} -div.sidebar:not(.margin) p { - margin-bottom: 0; -} -div.sidebar:not(.margin) p.sidebar-title { - display: none; -} -div.sidebar:not(.margin) div.topic { - padding: .5em 0; - background-color: transparent; - border: none; -} -div.sidebar:not(.margin) pre { - margin: .5em 0 1.5em 0; -} -div.sidebar:not(.margin) div[class*="highlight-"] { - margin-right: .5em; -} -div.sidebar:not(.margin) .admonition { - margin-right: .5em; - background-color: #ffffff; -} -@media (min-width:768px) { - div.sidebar:not(.margin) { - width: 50%; - margin-left: 1.5em; - margin-right: -28%; - } -} - - -main.bd-content #main-content dl.simple dt { - margin-top: .8em; -} -main.bd-content #main-content dl.simple dt:nth-of-type(1) { - margin-top: 0; -} -main.bd-content #main-content dl.simple dd { - margin-top: .8em; -} -main.bd-content #main-content dl.simple dt + dd { - margin-top: 0; -} - -.prev-next-bottom { - margin: 20px 0 30px 0; -} -.prev-next-bottom a.left-prev, .prev-next-bottom a.right-next { - padding: 5px 10px; - border: 1px solid rgba(0,0,0,.2); - max-width: 45%; - overflow-x: hidden; - color: rgba(0,0,0,.65); - border-radius: 10px; -} - -/* Local navigation */ -li.nav-item.toc-entry { - line-height: 1.25em; - margin-bottom: 0.25em; -} - -span.guilabel, span.menuselection { - border: none; - background: #e7f2fa; - border-radius: 4px; - padding: 4px 5px; - font-size: 90%; - font-weight: bold; - font-style: italic; - white-space: nowrap; -} - - -/* - * extensions - */ - -/* definitions */ -dl.py.function { - margin-bottom: 5rem; -} -dl.py.function > dt { - background-color: var(--codeblock-color); - padding: 4px 5px; -} -dl.py.function > dt:target { - background-color: var(--target-color); -} -dl.field-list > dt { - padding-left: 0; -} - -/* code blocks */ -div.viewcode-block:target { - padding: 10px 10px; - background-color: var(--codeblock-color); - border-top: 1px solid var(--codeblock-color); - border-bottom: 1px solid var(--codeblock-color); -} - -/* Hint for providing the Unicode Value of an fa-icon in CSS as ASCII - replace the "&#x" part from the unicode HTML5 literal - displayed on the font-awesome website cheatsheet e.g. - http://fontawesome.io/cheatsheet/ - - fa-wrench [] - - by a backslash like: - - .fa-wrench:before { - content: "\f0ad"; - } -*/ - -.icon-wrench:before, -.fa-wrench:before { - content: "\f0ad"; -} diff --git a/docs/source/_static/print.css b/docs/source/_static/print.css deleted file mode 100644 index 8dbc2d5794..0000000000 --- a/docs/source/_static/print.css +++ /dev/null @@ -1,3 +0,0 @@ -.tooltip { - display: none; -} diff --git a/docs/source/_templates/page.html b/docs/source/_templates/page.html deleted file mode 100644 index 35e79d84c1..0000000000 --- a/docs/source/_templates/page.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "!page.html" %} - -{% set css_files = css_files + ["_static/custom.css"] %} - -{% block footer %} - -{% endblock %} \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index c1cb7ad6f7..5bc2f084a2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -26,6 +26,8 @@ # General information about the project. project = "plone.restapi" +author = "Plone Community" +trademark_name = "Plone" thisyear = datetime.datetime.now().year copyright = "2014-%s, Plone Foundation" % thisyear @@ -159,50 +161,85 @@ def patch_pygments_to_highlight_jsonschema(): # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = "sphinx_book_theme" +html_theme = "plone_sphinx_theme" +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. html_logo = "_static/logo.svg" -html_favicon = "_static/favicon.ico" -html_css_files = ["custom.css", ("print.css", {"media": "print"})] +# 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 = "_static/favicon.ico" # See http://sphinx-doc.org/ext/todo.html#confval-todo_include_todos todo_include_todos = True +# 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} + +# 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