From e0c70e7a32649de8d0ebe127cd71c4246e05a875 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Mon, 12 Jan 2026 16:16:42 +0100
Subject: [PATCH 01/38] add the ability to export users as csv file
---
.../restapi/services/users/configure.zcml | 14 ++
src/plone/restapi/services/users/get.py | 130 +++++++++++++++---
.../restapi/tests/test_services_users.py | 101 +++++++-------
3 files changed, 174 insertions(+), 71 deletions(-)
diff --git a/src/plone/restapi/services/users/configure.zcml b/src/plone/restapi/services/users/configure.zcml
index b3c48ddaee..5c0066fb74 100644
--- a/src/plone/restapi/services/users/configure.zcml
+++ b/src/plone/restapi/services/users/configure.zcml
@@ -12,6 +12,15 @@
name="@users"
/>
+
+
+
+
diff --git a/src/plone/restapi/services/users/get.py b/src/plone/restapi/services/users/get.py
index 235639c785..eb24f2537c 100644
--- a/src/plone/restapi/services/users/get.py
+++ b/src/plone/restapi/services/users/get.py
@@ -1,13 +1,16 @@
from AccessControl import getSecurityManager
from Acquisition import aq_inner
+from csv import writer
from itertools import chain
from plone.app.workflow.browser.sharing import merge_search_results
from plone.namedfile.browser import ALLOWED_INLINE_MIMETYPES
from plone.namedfile.browser import DISALLOWED_INLINE_MIMETYPES
from plone.namedfile.browser import USE_DENYLIST
from plone.namedfile.utils import stream_data
+from plone.restapi.interfaces import IExpandableElement
from plone.restapi.interfaces import ISerializeToJson
from plone.restapi.permissions import PloneManageUsers
+from plone.restapi.services import _no_content_marker
from plone.restapi.services import Service
from Products.CMFCore.utils import getToolByName
from Products.CMFPlone.utils import normalizeString
@@ -19,12 +22,19 @@
from urllib.parse import parse_qs
from urllib.parse import quote
from zExceptions import BadRequest
+from zExceptions import NotFound
+from zExceptions import Unauthorized
+from zope.component import adapter
from zope.component import getMultiAdapter
from zope.component import queryMultiAdapter
from zope.component.hooks import getSite
from zope.interface import implementer
+from zope.interface import Interface
from zope.publisher.interfaces import IPublishTraverse
+import json
+import tempfile
+
DEFAULT_SEARCH_RESULTS_LIMIT = 25
@@ -75,22 +85,19 @@ def isDefaultPortrait(value):
)
-@implementer(IPublishTraverse)
-class UsersGet(Service):
- def __init__(self, context, request):
- super().__init__(context, request)
- self.params = []
+@implementer(IExpandableElement)
+@adapter(Interface, Interface)
+class Users:
+ def __init__(self, context, request, params):
+ self.context = context
+ self.request = request
+ self.params = params
portal = getSite()
self.portal_membership = getToolByName(portal, "portal_membership")
self.acl_users = getToolByName(portal, "acl_users")
self.query = parse_qs(self.request["QUERY_STRING"])
self.search_term = self.query.get("search", [""])[0]
- def publishTraverse(self, request, name):
- # Consume any path segments after /@users as parameters
- self.params.append(name)
- return self
-
@property
def _get_user_id(self):
if len(self.params) != 1:
@@ -208,10 +215,9 @@ def reply(self):
(user, self.request), ISerializeToJson
)
result.append(serializer())
- return result
+ return result, len(result)
else:
- self.request.response.setStatus(401)
- return
+ raise Unauthorized()
else:
raise BadRequest("Parameters supplied are not valid")
@@ -224,10 +230,9 @@ def reply(self):
(user, self.request), ISerializeToJson
)
result.append(serializer())
- return result
+ return result, len(result)
else:
- self.request.response.setStatus(401)
- return
+ raise Unauthorized()
# Some is asking one user, check if the logged in user is asking
# their own information or if they are a Manager
@@ -239,13 +244,96 @@ def reply(self):
# we retrieve the user on the user id not the username
user = self._get_user(self._get_user_id)
if not user:
- self.request.response.setStatus(404)
- return
+ raise NotFound()
serializer = queryMultiAdapter((user, self.request), ISerializeToJson)
return serializer()
else:
- self.request.response.setStatus(401)
- return
+ raise Unauthorized()
+
+ def reply_root_csv(self):
+ if len(self.params) > 0:
+ raise BadRequest("You may not request a CSV reply for a specific user.")
+
+ if self.has_permission_to_enumerate():
+ file_descriptor, file_path = tempfile.mkstemp(
+ suffix=".csv", prefix="users_"
+ )
+ with open(file_path, "w") as stream:
+ csv_writer = writer(stream)
+ csv_writer.writerow(
+ ["id", "username", "fullname", "email", "roles", "groups"]
+ )
+
+ for user in self._get_users():
+ serializer = queryMultiAdapter(
+ (user, self.request), ISerializeToJson
+ )
+ user = serializer()
+ csv_writer.writerow(
+ [
+ user["id"],
+ user["username"],
+ user["fullname"],
+ user["email"],
+ ", ".join(user["roles"]),
+ ", ".join(g["id"] for g in user["groups"]["items"]),
+ ]
+ )
+ with open(file_path, "rb") as stream:
+ content = stream.read()
+ else:
+ raise Unauthorized()
+
+ response = self.request.response
+ response.setHeader("Content-Type", "text/csv")
+ response.setHeader("Content-Length", len(content))
+ response.setHeader("Content-Disposition", "attachment; filename=users.csv")
+ return content
+
+ def __call__(self, expand=False):
+ result = {"users": {"@id": f"{self.context.absolute_url()}/@users"}}
+ if not expand:
+ return result
+ if self.request.getHeader("Accept") == "text/csv":
+ result["users"]["items"] = self.reply_root_csv()
+ return result
+ else:
+ if len(self.params) > 0:
+ result["users"] = self.reply()
+ return result
+ items, items_total = self.reply()
+
+ result["users"]["items"] = items
+ result["users"]["items_total"] = items_total
+ return result
+
+
+@implementer(IPublishTraverse)
+class UsersGet(Service):
+ """Get users."""
+
+ def __init__(self, context, request):
+ super().__init__(context, request)
+ self.params = []
+
+ def publishTraverse(self, request, name):
+ # Consume any path segments after /@users as parameters
+ self.params.append(name)
+ return self
+
+ def reply(self):
+ users = Users(self.context, self.request, self.params)
+ return users(expand=True)["users"]
+
+ 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=(", ", ": ")
+ )
@implementer(IPublishTraverse)
@@ -297,7 +385,7 @@ def render(self):
portrait = self.portal_membership.getPersonalPortrait(current_user_id)
else:
raise Exception(
- "Must supply exactly zero (own portrait) or one parameter " "(user id)"
+ "Must supply exactly zero (own portrait) or one parameter (user id)"
)
# User uploaded portraits have a meta_type of "Image"
if not portrait or isDefaultPortrait(portrait):
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index ced72b0eeb..b4112b3b77 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -7,7 +7,7 @@
from plone.app.testing import TEST_USER_ID
from plone.app.testing import TEST_USER_PASSWORD
from plone.restapi.bbb import ISecuritySchema
-from plone.restapi.services.users.get import UsersGet
+from plone.restapi.services.users.get import Users
from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
from plone.restapi.testing import RelativeSession
from Products.CMFCore.permissions import SetOwnPassword
@@ -35,7 +35,6 @@ def test_extract_media_type(self):
class TestUsersEndpoint(unittest.TestCase):
-
layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
def setUp(self):
@@ -129,12 +128,12 @@ def test_list_users(self):
response = self.api_session.get("/@users")
self.assertEqual(200, response.status_code)
- self.assertEqual(4, len(response.json()))
- user_ids = [user["id"] for user in response.json()]
+ self.assertEqual(4, len(response.json()["items"]))
+ user_ids = [user["id"] for user in response.json()["items"]]
self.assertIn("admin", user_ids)
self.assertIn("test_user_1_", user_ids)
self.assertIn("noam", user_ids)
- noam = [x for x in response.json() if x.get("username") == "noam"][0]
+ noam = [x for x in response.json()["items"] if x.get("username") == "noam"][0]
self.assertEqual("noam", noam.get("id"))
self.assertEqual(self.portal.absolute_url() + "/@users/noam", noam.get("@id"))
self.assertEqual("noam.chomsky@example.com", noam.get("email"))
@@ -153,7 +152,6 @@ def test_list_users_without_being_manager(self):
noam_api_session.close()
def test_list_users_as_anonymous(self):
-
response = self.anon_api_session.get("/@users")
self.assertEqual(response.status_code, 401)
@@ -162,13 +160,13 @@ def test_list_users_filtered(self):
"/@users?groups-filter:list=Reviewers&groups-filter:list=Administrators"
)
self.assertEqual(200, response.status_code)
- self.assertEqual(1, len(response.json()))
- user_ids = [user["id"] for user in response.json()]
+ self.assertEqual(1, len(response.json()["items"]))
+ user_ids = [user["id"] for user in response.json()["items"]]
self.assertIn("otheruser", user_ids)
response = self.api_session.get("/@users?groups-filter:list=Administrators")
self.assertEqual(200, response.status_code)
- user_ids = [user["id"] for user in response.json()]
+ user_ids = [user["id"] for user in response.json()["items"]]
self.assertNotIn("otheruser", user_ids)
def test_add_user(self):
@@ -426,21 +424,23 @@ def test_get_search_user_with_filter(self):
response = self.api_session.get("/@users", params={"query": "noa"})
self.assertEqual(response.status_code, 200)
- self.assertEqual(len(response.json()), 1)
- self.assertEqual("noam", response.json()[0].get("id"))
+ self.assertEqual(len(response.json()["items"]), 1)
+ self.assertEqual("noam", response.json()["items"][0].get("id"))
self.assertEqual(
self.portal.absolute_url() + "/@users/noam",
- response.json()[0].get("@id"),
+ response.json()["items"][0].get("@id"),
)
- self.assertEqual("noam.chomsky@example.com", response.json()[0].get("email"))
self.assertEqual(
- "Noam Avram Chomsky", response.json()[0].get("fullname")
+ "noam.chomsky@example.com", response.json()["items"][0].get("email")
+ )
+ self.assertEqual(
+ "Noam Avram Chomsky", response.json()["items"][0].get("fullname")
) # noqa
response = self.api_session.get("/@users", params={"query": "howa"})
self.assertEqual(response.status_code, 200)
- self.assertEqual(len(response.json()), 1)
- self.assertEqual("howard", response.json()[0].get("id"))
+ self.assertEqual(len(response.json()["items"]), 1)
+ self.assertEqual("howard", response.json()["items"][0].get("id"))
def test_get_search_user_with_filter_as_anonymous(self):
response = self.api_session.post(
@@ -1323,41 +1323,42 @@ def test_user_with_datetime(self):
self.assertIn("registration_datetime", response.json())
# Not testable via the service, hence unittest
+
def test_get_users_filtering(self):
- class MockUsersGet(UsersGet):
- def __init__(self):
- class MockUser:
- def __init__(self, userid):
- self.userid = userid
-
- def getProperty(self, key, default):
- return "Full Name " + self.userid
-
- class MockAclUsers:
- def searchUsers(self, **kw):
- return [
- {"userid": "user2"},
- {"userid": "user1"},
- {"userid": "NONEUSER"},
- ]
-
- self.acl_users = MockAclUsers()
-
- class MockPortalMembership:
- def getMemberById(self, userid):
- if userid == "NONEUSER":
- return None
- else:
- return MockUser(userid)
-
- self.portal_membership = MockPortalMembership()
-
- mockService = MockUsersGet()
- users = mockService._get_users(foo="bar")
- # Sorted by full name. None does not break and is filtered.
- self.assertEqual(len(users), 2)
- self.assertEqual(users[0].userid, "user1")
- self.assertEqual(users[1].userid, "user2")
+ class MockUser:
+ def __init__(self, userid):
+ self.userid = userid
+
+ def getProperty(self, key, default=None):
+ return "Full Name " + self.userid
+
+ class MockAclUsers:
+ def searchUsers(self, **kw):
+ return [
+ {"userid": "user2"},
+ {"userid": "user1"},
+ {"userid": "NONEUSER"},
+ ]
+
+ class MockPortalMembership:
+ def getMemberById(self, userid):
+ if userid == "NONEUSER":
+ return None
+ return MockUser(userid)
+
+ # Create Users instance *without* calling its __init__
+ users = Users.__new__(Users)
+
+ # Inject only what _get_users actually needs
+ users.acl_users = MockAclUsers()
+ users.portal_membership = MockPortalMembership()
+
+ result = users._get_users(foo="bar")
+
+ # Sorted by normalized fullname; None users filtered out
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0].userid, "user1")
+ self.assertEqual(result[1].userid, "user2")
def test_siteadm_not_update_manager(self):
self.set_siteadm()
From f9f343210f4239675d1d882251b0cdfcec6387ee Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Thu, 15 Jan 2026 15:10:47 +0100
Subject: [PATCH 02/38] Add support for importing users from a CSV file
---
src/plone/restapi/services/users/add.py | 61 ++++++++++++++-----
.../restapi/services/users/configure.zcml | 9 +++
.../restapi/tests/test_services_users.py | 13 ++++
3 files changed, 68 insertions(+), 15 deletions(-)
diff --git a/src/plone/restapi/services/users/add.py b/src/plone/restapi/services/users/add.py
index 31fe6c599e..0014e23b5b 100644
--- a/src/plone/restapi/services/users/add.py
+++ b/src/plone/restapi/services/users/add.py
@@ -12,6 +12,8 @@
from Products.CMFPlone.PasswordResetTool import ExpiredRequestError
from Products.CMFPlone.PasswordResetTool import InvalidRequestError
from Products.CMFPlone.RegistrationTool import get_member_by_login_name
+from zExceptions import Forbidden
+from zExceptions import HTTPNotAcceptable as NotAcceptable
from zope.component import getAdapter
from zope.component import getMultiAdapter
from zope.component import queryMultiAdapter
@@ -21,6 +23,8 @@
from zope.interface import implementer
from zope.publisher.interfaces import IPublishTraverse
+import csv
+import io
import plone.protect.interfaces
@@ -31,6 +35,7 @@ class UsersPost(Service):
def __init__(self, context, request):
super().__init__(context, request)
self.params = []
+ self.errors = []
def publishTraverse(self, request, name):
# Consume any path segments after /@users as parameters
@@ -131,12 +136,30 @@ def reply(self):
portal = getSite()
- # validate important data
- data = json_body(self.request)
- self.errors = []
- self.validate_input_data(portal, data)
- security = getAdapter(self.context, ISecuritySchema)
- registration = getToolByName(self.context, "portal_registration")
+ if self.request.getHeader("Content-Type") == "text/csv":
+ if len(self.params) > 0:
+ raise NotAcceptable(_(""))
+ data = []
+ stream = io.TextIOWrapper(
+ self.request.stdin,
+ encoding="utf-8",
+ newline="",
+ )
+ try:
+ reader = csv.DictReader(stream)
+ for row in reader:
+ # validate important data
+ self.validate_input_data(portal, row)
+ data.append(row)
+ finally:
+ # Flush the text wrapper & disconnect it from the underlying buffer.
+ # Prevents the buffer from being closed too early.
+ # The TextIOWrapper (`stream`) is unusable after being detached.
+ stream.detach()
+ else:
+ # validate important data
+ data = json_body(self.request)
+ self.validate_input_data(portal, data)
general_usage_error = (
"Either post to @users to create a user or use "
@@ -152,11 +175,7 @@ def reply(self):
# Add a portal member
if not self.can_add_member:
- return self._error(
- 403,
- "Forbidden",
- _("You need AddPortalMember permission."),
- )
+ raise Forbidden(_("You need AddPortalMember permission."))
if self.errors:
self.request.response.setStatus(400)
@@ -173,6 +192,19 @@ def reply(self):
)
)
+ if isinstance(data, list):
+ result = []
+ for i in data:
+ user = self._add_user(i)
+ result.append(user)
+ return result
+ return self._add_user(data)
+
+ def _add_user(self, data):
+ portal = getSite()
+ security = getAdapter(self.context, ISecuritySchema)
+ registration = getToolByName(portal, "portal_registration")
+
username = data.pop("username", None)
email = data.pop("email", None)
password = data.pop("password", None)
@@ -205,7 +237,6 @@ def reply(self):
password = registration.generatePassword()
# Create user
try:
- registration = getToolByName(portal, "portal_registration")
user = registration.addMember(
username, password, roles, properties=properties
)
@@ -219,8 +250,7 @@ def reply(self):
)
if user_id != login_name:
- # The user id differs from the login name. Set the login
- # name correctly.
+ # The user id differs from the login name. Set the login name correctly.
pas = getToolByName(self.context, "acl_users")
pas.updateLoginName(user_id, login_name)
@@ -320,7 +350,8 @@ def update_password(self, data):
except ExpiredRequestError:
return self._error(
403,
- _("Expired Token", "The reset_token is expired."),
+ "Expired Token",
+ _("The reset_token is expired."),
)
return
diff --git a/src/plone/restapi/services/users/configure.zcml b/src/plone/restapi/services/users/configure.zcml
index 5c0066fb74..e239912309 100644
--- a/src/plone/restapi/services/users/configure.zcml
+++ b/src/plone/restapi/services/users/configure.zcml
@@ -37,6 +37,15 @@
name="@users"
/>
+
+
Date: Thu, 15 Jan 2026 15:11:09 +0100
Subject: [PATCH 03/38] Add changelog entry
---
news/+users_import_export.feature | 1 +
1 file changed, 1 insertion(+)
create mode 100644 news/+users_import_export.feature
diff --git a/news/+users_import_export.feature b/news/+users_import_export.feature
new file mode 100644
index 0000000000..353d1f8c10
--- /dev/null
+++ b/news/+users_import_export.feature
@@ -0,0 +1 @@
+Add CSV import and export support to the @users endpoint. @jnptk
From f95684cf04f8ea00793a4fd03caaf95f3a18ff56 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Fri, 16 Jan 2026 14:06:40 +0100
Subject: [PATCH 04/38] Don't make Users expandable, fixes the tests
---
src/plone/restapi/services/users/get.py | 5 -----
1 file changed, 5 deletions(-)
diff --git a/src/plone/restapi/services/users/get.py b/src/plone/restapi/services/users/get.py
index eb24f2537c..ff03fe2436 100644
--- a/src/plone/restapi/services/users/get.py
+++ b/src/plone/restapi/services/users/get.py
@@ -7,7 +7,6 @@
from plone.namedfile.browser import DISALLOWED_INLINE_MIMETYPES
from plone.namedfile.browser import USE_DENYLIST
from plone.namedfile.utils import stream_data
-from plone.restapi.interfaces import IExpandableElement
from plone.restapi.interfaces import ISerializeToJson
from plone.restapi.permissions import PloneManageUsers
from plone.restapi.services import _no_content_marker
@@ -24,12 +23,10 @@
from zExceptions import BadRequest
from zExceptions import NotFound
from zExceptions import Unauthorized
-from zope.component import adapter
from zope.component import getMultiAdapter
from zope.component import queryMultiAdapter
from zope.component.hooks import getSite
from zope.interface import implementer
-from zope.interface import Interface
from zope.publisher.interfaces import IPublishTraverse
import json
@@ -85,8 +82,6 @@ def isDefaultPortrait(value):
)
-@implementer(IExpandableElement)
-@adapter(Interface, Interface)
class Users:
def __init__(self, context, request, params):
self.context = context
From 4e2ccc408db34ece7da51d9f4157695a48b476be Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Fri, 16 Jan 2026 14:07:18 +0100
Subject: [PATCH 05/38] Remove adapter from zcml
---
src/plone/restapi/services/users/configure.zcml | 5 -----
1 file changed, 5 deletions(-)
diff --git a/src/plone/restapi/services/users/configure.zcml b/src/plone/restapi/services/users/configure.zcml
index e239912309..c54ca66742 100644
--- a/src/plone/restapi/services/users/configure.zcml
+++ b/src/plone/restapi/services/users/configure.zcml
@@ -62,9 +62,4 @@
name="@portrait"
/>
-
-
From fbca8297e982202b285e8d08bd73c2ab05611064 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Fri, 16 Jan 2026 14:07:59 +0100
Subject: [PATCH 06/38] Add error messages
---
src/plone/restapi/services/users/get.py | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/src/plone/restapi/services/users/get.py b/src/plone/restapi/services/users/get.py
index ff03fe2436..301c8ce3c0 100644
--- a/src/plone/restapi/services/users/get.py
+++ b/src/plone/restapi/services/users/get.py
@@ -212,7 +212,9 @@ def reply(self):
result.append(serializer())
return result, len(result)
else:
- raise Unauthorized()
+ raise Unauthorized(
+ "You are not authorized to access this resource."
+ )
else:
raise BadRequest("Parameters supplied are not valid")
@@ -227,7 +229,7 @@ def reply(self):
result.append(serializer())
return result, len(result)
else:
- raise Unauthorized()
+ raise Unauthorized("You are not authorized to access this resource.")
# Some is asking one user, check if the logged in user is asking
# their own information or if they are a Manager
@@ -239,11 +241,11 @@ def reply(self):
# we retrieve the user on the user id not the username
user = self._get_user(self._get_user_id)
if not user:
- raise NotFound()
+ raise NotFound(f"User ${self._get_user_id} does not exist.")
serializer = queryMultiAdapter((user, self.request), ISerializeToJson)
return serializer()
else:
- raise Unauthorized()
+ raise Unauthorized("You are not authorized to access this resource.")
def reply_root_csv(self):
if len(self.params) > 0:
@@ -277,7 +279,7 @@ def reply_root_csv(self):
with open(file_path, "rb") as stream:
content = stream.read()
else:
- raise Unauthorized()
+ raise Unauthorized("You are not authorized to access this resource.")
response = self.request.response
response.setHeader("Content-Type", "text/csv")
From ae0784f95546c4d489d364febbdf2aaba9e8ba92 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Fri, 16 Jan 2026 14:08:53 +0100
Subject: [PATCH 07/38] Set correct response headers
---
src/plone/restapi/services/users/get.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/plone/restapi/services/users/get.py b/src/plone/restapi/services/users/get.py
index 301c8ce3c0..0847f6f562 100644
--- a/src/plone/restapi/services/users/get.py
+++ b/src/plone/restapi/services/users/get.py
@@ -195,6 +195,8 @@ def has_permission_to_access_user_info(self):
)
def reply(self):
+ self.request.response.setStatus(200)
+ self.request.response.setHeader("Content-Type", "application/json")
if len(self.query) > 0 and len(self.params) == 0:
query = self.query.get("query", "")
groups_filter = self.query.get("groups-filter:list", [])
From 0355fb180101442a54545fdb866412d9708917bb Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Fri, 16 Jan 2026 14:09:18 +0100
Subject: [PATCH 08/38] Run tests
---
.../restapi/tests/http-examples/users.resp | 98 ++++++++++---------
.../tests/http-examples/users_anonymous.resp | 6 +-
.../http-examples/users_anonymous_get.resp | 6 +-
.../users_filtered_by_groups.resp | 64 ++++++------
.../users_filtered_by_username.resp | 64 ++++++------
.../tests/http-examples/users_searched.resp | 64 ++++++------
.../http-examples/users_unauthorized.resp | 6 +-
.../http-examples/users_unauthorized_get.resp | 6 +-
8 files changed, 173 insertions(+), 141 deletions(-)
diff --git a/src/plone/restapi/tests/http-examples/users.resp b/src/plone/restapi/tests/http-examples/users.resp
index a8e179eaff..a82e488074 100644
--- a/src/plone/restapi/tests/http-examples/users.resp
+++ b/src/plone/restapi/tests/http-examples/users.resp
@@ -1,53 +1,57 @@
HTTP/1.1 200 OK
Content-Type: application/json
-[
- {
- "@id": "http://localhost:55001/plone/@users/admin",
- "description": "This is an admin user",
- "email": "admin@example.com",
- "fullname": "Administrator",
- "groups": {
- "@id": "http://localhost:55001/plone/@users",
- "items": [
- {
- "id": "AuthenticatedUsers",
- "title": "AuthenticatedUsers"
- }
+{
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "@id": "http://localhost:55001/plone/@users/admin",
+ "description": "This is an admin user",
+ "email": "admin@example.com",
+ "fullname": "Administrator",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ }
+ ],
+ "items_total": 1
+ },
+ "home_page": "http://www.example.com",
+ "id": "admin",
+ "location": "Berlin",
+ "portrait": null,
+ "roles": [
+ "Manager"
],
- "items_total": 1
+ "username": "admin"
},
- "home_page": "http://www.example.com",
- "id": "admin",
- "location": "Berlin",
- "portrait": null,
- "roles": [
- "Manager"
- ],
- "username": "admin"
- },
- {
- "@id": "http://localhost:55001/plone/@users/test_user_1_",
- "description": "This is a test user",
- "email": "test@example.com",
- "fullname": "Test User",
- "groups": {
- "@id": "http://localhost:55001/plone/@users",
- "items": [
- {
- "id": "AuthenticatedUsers",
- "title": "AuthenticatedUsers"
- }
+ {
+ "@id": "http://localhost:55001/plone/@users/test_user_1_",
+ "description": "This is a test user",
+ "email": "test@example.com",
+ "fullname": "Test User",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ }
+ ],
+ "items_total": 1
+ },
+ "home_page": "http://www.example.com",
+ "id": "test_user_1_",
+ "location": "Bonn",
+ "portrait": null,
+ "roles": [
+ "Manager"
],
- "items_total": 1
- },
- "home_page": "http://www.example.com",
- "id": "test_user_1_",
- "location": "Bonn",
- "portrait": null,
- "roles": [
- "Manager"
- ],
- "username": "test-user"
- }
-]
+ "username": "test-user"
+ }
+ ],
+ "items_total": 2
+}
diff --git a/src/plone/restapi/tests/http-examples/users_anonymous.resp b/src/plone/restapi/tests/http-examples/users_anonymous.resp
index f858d607a9..61b9a20e5c 100644
--- a/src/plone/restapi/tests/http-examples/users_anonymous.resp
+++ b/src/plone/restapi/tests/http-examples/users_anonymous.resp
@@ -1,4 +1,8 @@
HTTP/1.1 401 Unauthorized
Content-Type: application/json
-null
+{
+ "context": "http://localhost:55001/plone",
+ "message": "You are not authorized to access this resource.",
+ "type": "Unauthorized"
+}
diff --git a/src/plone/restapi/tests/http-examples/users_anonymous_get.resp b/src/plone/restapi/tests/http-examples/users_anonymous_get.resp
index f858d607a9..61b9a20e5c 100644
--- a/src/plone/restapi/tests/http-examples/users_anonymous_get.resp
+++ b/src/plone/restapi/tests/http-examples/users_anonymous_get.resp
@@ -1,4 +1,8 @@
HTTP/1.1 401 Unauthorized
Content-Type: application/json
-null
+{
+ "context": "http://localhost:55001/plone",
+ "message": "You are not authorized to access this resource.",
+ "type": "Unauthorized"
+}
diff --git a/src/plone/restapi/tests/http-examples/users_filtered_by_groups.resp b/src/plone/restapi/tests/http-examples/users_filtered_by_groups.resp
index 7ea2e78da5..1d6b725746 100644
--- a/src/plone/restapi/tests/http-examples/users_filtered_by_groups.resp
+++ b/src/plone/restapi/tests/http-examples/users_filtered_by_groups.resp
@@ -1,34 +1,38 @@
HTTP/1.1 200 OK
Content-Type: application/json
-[
- {
- "@id": "http://localhost:55001/plone/@users/noam",
- "description": "Professor of Linguistics",
- "email": "noam.chomsky@example.com",
- "fullname": "Noam Avram Chomsky",
- "groups": {
- "@id": "http://localhost:55001/plone/@users?groups-filter%3Alist=Reviewers&groups-filter%3Alist=Site+Administrators",
- "items": [
- {
- "id": "AuthenticatedUsers",
- "title": "AuthenticatedUsers"
- },
- {
- "id": "Reviewers",
- "title": "Reviewers"
- }
+{
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "@id": "http://localhost:55001/plone/@users/noam",
+ "description": "Professor of Linguistics",
+ "email": "noam.chomsky@example.com",
+ "fullname": "Noam Avram Chomsky",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users?groups-filter%3Alist=Reviewers&groups-filter%3Alist=Site+Administrators",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ },
+ {
+ "id": "Reviewers",
+ "title": "Reviewers"
+ }
+ ],
+ "items_total": 2
+ },
+ "home_page": "web.mit.edu/chomsky",
+ "id": "noam",
+ "location": "Cambridge, MA",
+ "portrait": null,
+ "roles": [
+ "Member",
+ "Reviewer"
],
- "items_total": 2
- },
- "home_page": "web.mit.edu/chomsky",
- "id": "noam",
- "location": "Cambridge, MA",
- "portrait": null,
- "roles": [
- "Member",
- "Reviewer"
- ],
- "username": "noam"
- }
-]
+ "username": "noam"
+ }
+ ],
+ "items_total": 1
+}
diff --git a/src/plone/restapi/tests/http-examples/users_filtered_by_username.resp b/src/plone/restapi/tests/http-examples/users_filtered_by_username.resp
index d69cc23645..014ac95513 100644
--- a/src/plone/restapi/tests/http-examples/users_filtered_by_username.resp
+++ b/src/plone/restapi/tests/http-examples/users_filtered_by_username.resp
@@ -1,34 +1,38 @@
HTTP/1.1 200 OK
Content-Type: application/json
-[
- {
- "@id": "http://localhost:55001/plone/@users/noam",
- "description": "Professor of Linguistics",
- "email": "noam.chomsky@example.com",
- "fullname": "Noam Avram Chomsky",
- "groups": {
- "@id": "http://localhost:55001/plone/@users?query=oam",
- "items": [
- {
- "id": "AuthenticatedUsers",
- "title": "AuthenticatedUsers"
- },
- {
- "id": "Reviewers",
- "title": "Reviewers"
- }
+{
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "@id": "http://localhost:55001/plone/@users/noam",
+ "description": "Professor of Linguistics",
+ "email": "noam.chomsky@example.com",
+ "fullname": "Noam Avram Chomsky",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users?query=oam",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ },
+ {
+ "id": "Reviewers",
+ "title": "Reviewers"
+ }
+ ],
+ "items_total": 2
+ },
+ "home_page": "web.mit.edu/chomsky",
+ "id": "noam",
+ "location": "Cambridge, MA",
+ "portrait": null,
+ "roles": [
+ "Member",
+ "Reviewer"
],
- "items_total": 2
- },
- "home_page": "web.mit.edu/chomsky",
- "id": "noam",
- "location": "Cambridge, MA",
- "portrait": null,
- "roles": [
- "Member",
- "Reviewer"
- ],
- "username": "noam"
- }
-]
+ "username": "noam"
+ }
+ ],
+ "items_total": 1
+}
diff --git a/src/plone/restapi/tests/http-examples/users_searched.resp b/src/plone/restapi/tests/http-examples/users_searched.resp
index a7d26d8346..61919733c9 100644
--- a/src/plone/restapi/tests/http-examples/users_searched.resp
+++ b/src/plone/restapi/tests/http-examples/users_searched.resp
@@ -1,34 +1,38 @@
HTTP/1.1 200 OK
Content-Type: application/json
-[
- {
- "@id": "http://localhost:55001/plone/@users/noam",
- "description": "Professor of Linguistics",
- "email": "noam.chomsky@example.com",
- "fullname": "Noam Avram Chomsky",
- "groups": {
- "@id": "http://localhost:55001/plone/@users?search=avram",
- "items": [
- {
- "id": "AuthenticatedUsers",
- "title": "AuthenticatedUsers"
- },
- {
- "id": "Reviewers",
- "title": "Reviewers"
- }
+{
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "@id": "http://localhost:55001/plone/@users/noam",
+ "description": "Professor of Linguistics",
+ "email": "noam.chomsky@example.com",
+ "fullname": "Noam Avram Chomsky",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users?search=avram",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ },
+ {
+ "id": "Reviewers",
+ "title": "Reviewers"
+ }
+ ],
+ "items_total": 2
+ },
+ "home_page": "web.mit.edu/chomsky",
+ "id": "noam",
+ "location": "Cambridge, MA",
+ "portrait": null,
+ "roles": [
+ "Member",
+ "Reviewer"
],
- "items_total": 2
- },
- "home_page": "web.mit.edu/chomsky",
- "id": "noam",
- "location": "Cambridge, MA",
- "portrait": null,
- "roles": [
- "Member",
- "Reviewer"
- ],
- "username": "noam"
- }
-]
+ "username": "noam"
+ }
+ ],
+ "items_total": 1
+}
diff --git a/src/plone/restapi/tests/http-examples/users_unauthorized.resp b/src/plone/restapi/tests/http-examples/users_unauthorized.resp
index f858d607a9..61b9a20e5c 100644
--- a/src/plone/restapi/tests/http-examples/users_unauthorized.resp
+++ b/src/plone/restapi/tests/http-examples/users_unauthorized.resp
@@ -1,4 +1,8 @@
HTTP/1.1 401 Unauthorized
Content-Type: application/json
-null
+{
+ "context": "http://localhost:55001/plone",
+ "message": "You are not authorized to access this resource.",
+ "type": "Unauthorized"
+}
diff --git a/src/plone/restapi/tests/http-examples/users_unauthorized_get.resp b/src/plone/restapi/tests/http-examples/users_unauthorized_get.resp
index f858d607a9..61b9a20e5c 100644
--- a/src/plone/restapi/tests/http-examples/users_unauthorized_get.resp
+++ b/src/plone/restapi/tests/http-examples/users_unauthorized_get.resp
@@ -1,4 +1,8 @@
HTTP/1.1 401 Unauthorized
Content-Type: application/json
-null
+{
+ "context": "http://localhost:55001/plone",
+ "message": "You are not authorized to access this resource.",
+ "type": "Unauthorized"
+}
From c8db0310440c99c1597b4f2008cf27f57dd53679 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Fri, 16 Jan 2026 16:53:11 +0100
Subject: [PATCH 09/38] Document `GET` method
---
docs/source/endpoints/users.md | 14 ++++++++++++++
.../http-examples/users_get_csv_format.req | 3 +++
.../http-examples/users_get_csv_format.resp | 6 ++++++
src/plone/restapi/tests/test_documentation.py | 19 +++++++++++++++++++
4 files changed, 42 insertions(+)
create mode 100644 src/plone/restapi/tests/http-examples/users_get_csv_format.req
create mode 100644 src/plone/restapi/tests/http-examples/users_get_csv_format.resp
diff --git a/docs/source/endpoints/users.md b/docs/source/endpoints/users.md
index ced6498dfc..d6485c9f9f 100644
--- a/docs/source/endpoints/users.md
+++ b/docs/source/endpoints/users.md
@@ -56,6 +56,20 @@ The server will return a {term}`401 Unauthorized` status code.
:language: http
```
+### List all Users via CSV
+
+To download all users of a Plone site as a CSV file, send a `GET` request to the `/@users` endpoint from site root.
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/users_get_csv_format.req
+```
+
+Response:
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/users_get_csv_format.resp
+:language: http
+```
### Filtering the list of users
diff --git a/src/plone/restapi/tests/http-examples/users_get_csv_format.req b/src/plone/restapi/tests/http-examples/users_get_csv_format.req
new file mode 100644
index 0000000000..59e6dca5de
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/users_get_csv_format.req
@@ -0,0 +1,3 @@
+GET /plone/@users HTTP/1.1
+Accept: text/csv
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/src/plone/restapi/tests/http-examples/users_get_csv_format.resp b/src/plone/restapi/tests/http-examples/users_get_csv_format.resp
new file mode 100644
index 0000000000..fddb7ef7ae
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/users_get_csv_format.resp
@@ -0,0 +1,6 @@
+HTTP/1.1 200 OK
+Content-Type: text/csv; charset=utf-8
+
+id,username,fullname,email,roles,groups
+test_user_1_,test-user,,,Manager,AuthenticatedUsers
+admin,admin,,,Manager,AuthenticatedUsers
diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py
index 67b3620ba8..0111728d97 100644
--- a/src/plone/restapi/tests/test_documentation.py
+++ b/src/plone/restapi/tests/test_documentation.py
@@ -1043,6 +1043,25 @@ def test_documentation_users_searched_get(self):
response = self.api_session.get("@users", params={"search": "avram"})
save_request_and_response_for_docs("users_searched", response)
+ def test_documentation_users_csv_format_get(self):
+ url = f"{self.portal.absolute_url()}/@users"
+ response = self.api_session.post(
+ url,
+ json={
+ "email": "noam.chomsky@example.com",
+ "username": "noamchomsky",
+ "fullname": "Noam Avram Chomsky",
+ "home_page": "web.mit.edu/chomsky",
+ "description": "Professor of Linguistics",
+ "location": "Cambridge, MA",
+ "roles": ["Contributor"],
+ },
+ )
+ self.api_session.headers.update({"Content-Type": "text/csv"})
+ self.api_session.headers.update({"Accept": "text/csv"})
+ response = self.api_session.get(url)
+ save_request_and_response_for_docs("users_get_csv_format", response)
+
def test_documentation_users_created(self):
response = self.api_session.post(
"/@users",
From 45a7e4b6994b1c2d897306fa8a1725a67663d5a9 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Fri, 16 Jan 2026 17:44:20 +0100
Subject: [PATCH 10/38] Document `POST` method
---
.../http-examples/users_add_csv_format.req | 7 +++++
.../http-examples/users_add_csv_format.resp | 28 +++++++++++++++++++
src/plone/restapi/tests/test_documentation.py | 14 ++++++++++
3 files changed, 49 insertions(+)
create mode 100644 src/plone/restapi/tests/http-examples/users_add_csv_format.req
create mode 100644 src/plone/restapi/tests/http-examples/users_add_csv_format.resp
diff --git a/src/plone/restapi/tests/http-examples/users_add_csv_format.req b/src/plone/restapi/tests/http-examples/users_add_csv_format.req
new file mode 100644
index 0000000000..0580448e15
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.req
@@ -0,0 +1,7 @@
+POST /plone/@users HTTP/1.1
+Accept: text/csv
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: text/csv
+
+id,username,fullname,email,roles,location,password
+noam,noamchomsky,Noam Avran Chomsky,noam.chomsky@example.com,Contributor,"Cambridge, MA",password1234
diff --git a/src/plone/restapi/tests/http-examples/users_add_csv_format.resp b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
new file mode 100644
index 0000000000..fe75cbad1b
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
@@ -0,0 +1,28 @@
+HTTP/1.1 201 Created
+Content-Type: application/json
+Location: http://localhost:55001/plone/@users/noamchomsky
+
+[
+ {
+ "@id": "http://localhost:55001/plone/@users/noamchomsky",
+ "description": null,
+ "email": "noam.chomsky@example.com",
+ "fullname": "Noam Avran Chomsky",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ }
+ ],
+ "items_total": 1
+ },
+ "home_page": null,
+ "id": "noamchomsky",
+ "location": "Cambridge, MA",
+ "portrait": null,
+ "roles": [],
+ "username": "noamchomsky"
+ }
+]
diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py
index 0111728d97..1ea665e161 100644
--- a/src/plone/restapi/tests/test_documentation.py
+++ b/src/plone/restapi/tests/test_documentation.py
@@ -1093,6 +1093,20 @@ def test_documentation_users_add(self):
)
save_request_and_response_for_docs("users_add", response)
+ def test_documentation_users_csv_format_add(self):
+ url = f"{self.portal.absolute_url()}/@users"
+
+ content = b'id,username,fullname,email,roles,location,password\r\nnoam,noamchomsky,Noam Avran Chomsky,noam.chomsky@example.com,Contributor,"Cambridge, MA",password1234\r\n'
+
+ headers = {
+ "Accept": "text/csv",
+ "Authorization": "Basic YWRtaW46c2VjcmV0",
+ "Content-Type": "text/csv",
+ }
+
+ response = self.api_session.post(url, headers=headers, data=content)
+ save_request_and_response_for_docs("users_add_csv_format", response)
+
def test_documentation_users_update(self):
properties = {
"email": "noam.chomsky@example.com",
From 2e42fa5014852e148ea0e1dd2819ae480234454e Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Fri, 16 Jan 2026 17:45:00 +0100
Subject: [PATCH 11/38] Add tests for `GET` method
---
src/plone/restapi/tests/test_services_users.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index 257a466c12..4041d9a1e1 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -169,6 +169,15 @@ def test_list_users_filtered(self):
user_ids = [user["id"] for user in response.json()["items"]]
self.assertNotIn("otheruser", user_ids)
+ def test_list_users_via_csv(self):
+ resp = self.api_session.get("/@users", headers={"Accept": "text/csv"})
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn("Content-Disposition", resp.headers)
+ self.assertEqual(resp.headers["Content-Type"], "text/csv; charset=utf-8")
+ content = b'id,username,fullname,email,roles,groups\r\nadmin,admin,,,Manager,AuthenticatedUsers\r\ntest_user_1_,test-user,,,Manager,AuthenticatedUsers\r\nnoam,noam,Noam Avram Chomsky,noam.chomsky@example.com,Member,AuthenticatedUsers\r\notheruser,otheruser,Other user,otheruser@example.com,"Member, Reviewer","AuthenticatedUsers, Reviewers"\r\n'
+ self.assertEqual(resp.content, content)
+
def test_add_user(self):
response = self.api_session.post(
"/@users",
From 4eb58a180e89b68d1760e0b3d9996044000545c0 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Sat, 17 Jan 2026 15:19:01 +0100
Subject: [PATCH 12/38] Apply suggestions from code review
Co-authored-by: Steve Piercy
---
docs/source/endpoints/users.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/source/endpoints/users.md b/docs/source/endpoints/users.md
index d6485c9f9f..fe4e02c843 100644
--- a/docs/source/endpoints/users.md
+++ b/docs/source/endpoints/users.md
@@ -56,9 +56,9 @@ The server will return a {term}`401 Unauthorized` status code.
:language: http
```
-### List all Users via CSV
+### List all users via CSV
-To download all users of a Plone site as a CSV file, send a `GET` request to the `/@users` endpoint from site root.
+To download all users of a Plone site as a CSV file, send a `GET` request to the `/@users` endpoint from the site root.
```{eval-rst}
.. http:example:: curl httpie python-requests
From 6f73ad2bfe103ee39881b4a62fb7b597c5480d2b Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Thu, 22 Jan 2026 11:52:19 +0100
Subject: [PATCH 13/38] Sort users by username if fullname is not set
---
src/plone/restapi/services/users/get.py | 3 ++-
src/plone/restapi/tests/test_services_users.py | 5 ++++-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/plone/restapi/services/users/get.py b/src/plone/restapi/services/users/get.py
index 0847f6f562..1c378895aa 100644
--- a/src/plone/restapi/services/users/get.py
+++ b/src/plone/restapi/services/users/get.py
@@ -106,7 +106,8 @@ def _get_user(self, user_id):
def _sort_users(users: Iterable[MemberData]) -> Sequence[MemberData]:
"""users is an iterable of MemberData objects, None is not accepted"""
return sorted(
- users, key=lambda x: normalizeString(x.getProperty("fullname", ""))
+ users,
+ key=lambda x: normalizeString(x.getProperty("fullname") or x.getUserName()),
)
def _principal_search_results(
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index 4041d9a1e1..a683c57c0c 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -175,7 +175,7 @@ def test_list_users_via_csv(self):
self.assertEqual(resp.status_code, 200)
self.assertIn("Content-Disposition", resp.headers)
self.assertEqual(resp.headers["Content-Type"], "text/csv; charset=utf-8")
- content = b'id,username,fullname,email,roles,groups\r\nadmin,admin,,,Manager,AuthenticatedUsers\r\ntest_user_1_,test-user,,,Manager,AuthenticatedUsers\r\nnoam,noam,Noam Avram Chomsky,noam.chomsky@example.com,Member,AuthenticatedUsers\r\notheruser,otheruser,Other user,otheruser@example.com,"Member, Reviewer","AuthenticatedUsers, Reviewers"\r\n'
+ content = b'id,username,fullname,email,roles,groups\r\nadmin,admin,,,Manager,AuthenticatedUsers\r\nnoam,noam,Noam Avram Chomsky,noam.chomsky@example.com,Member,AuthenticatedUsers\r\notheruser,otheruser,Other user,otheruser@example.com,"Member, Reviewer","AuthenticatedUsers, Reviewers"\r\ntest_user_1_,test-user,,,Manager,AuthenticatedUsers\r\n'
self.assertEqual(resp.content, content)
def test_add_user(self):
@@ -1354,6 +1354,9 @@ def __init__(self, userid):
def getProperty(self, key, default=None):
return "Full Name " + self.userid
+ def getUserName(self):
+ return self.userid
+
class MockAclUsers:
def searchUsers(self, **kw):
return [
From ed1fb276c61477991aea74e87a775be39a904933 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Thu, 22 Jan 2026 11:53:48 +0100
Subject: [PATCH 14/38] Explicitly return 201 on user creation
---
src/plone/restapi/services/users/add.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/plone/restapi/services/users/add.py b/src/plone/restapi/services/users/add.py
index 0014e23b5b..965db9ed62 100644
--- a/src/plone/restapi/services/users/add.py
+++ b/src/plone/restapi/services/users/add.py
@@ -192,6 +192,7 @@ def reply(self):
)
)
+ self.request.response.setStatus(201)
if isinstance(data, list):
result = []
for i in data:
From d90d4833461bab99549dfc03d74feab5281c8a94 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Fri, 23 Jan 2026 11:29:01 +0100
Subject: [PATCH 15/38] Explicitly get user by username
---
src/plone/restapi/tests/test_services_users.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index a683c57c0c..3e695a3cd4 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -386,7 +386,7 @@ def test_add_users_via_csv(self):
transaction.commit()
self.assertEqual(resp.status_code, 201)
- dprince = api.user.get("dprince")
+ dprince = api.user.get(username="dprince")
self.assertEqual(dprince.getProperty("email"), "dprince@example.com")
self.assertTrue(api.user.get_roles("dprince"), "Member")
From e4e541a156e073b94561c01aedda930b1d241826 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Mon, 26 Jan 2026 19:29:45 +0100
Subject: [PATCH 16/38] Update users get examples
---
src/plone/restapi/tests/http-examples/users_get_csv_format.resp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/plone/restapi/tests/http-examples/users_get_csv_format.resp b/src/plone/restapi/tests/http-examples/users_get_csv_format.resp
index fddb7ef7ae..f354aa1a1d 100644
--- a/src/plone/restapi/tests/http-examples/users_get_csv_format.resp
+++ b/src/plone/restapi/tests/http-examples/users_get_csv_format.resp
@@ -2,5 +2,5 @@ HTTP/1.1 200 OK
Content-Type: text/csv; charset=utf-8
id,username,fullname,email,roles,groups
-test_user_1_,test-user,,,Manager,AuthenticatedUsers
admin,admin,,,Manager,AuthenticatedUsers
+test_user_1_,test-user,,,Manager,AuthenticatedUsers
From a91d62feb92489fb4be1ba9a964008a015ba72ee Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Mon, 26 Jan 2026 19:32:07 +0100
Subject: [PATCH 17/38] Get file through http body
---
src/plone/restapi/services/users/add.py | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/src/plone/restapi/services/users/add.py b/src/plone/restapi/services/users/add.py
index 965db9ed62..0b0a2860b7 100644
--- a/src/plone/restapi/services/users/add.py
+++ b/src/plone/restapi/services/users/add.py
@@ -12,6 +12,7 @@
from Products.CMFPlone.PasswordResetTool import ExpiredRequestError
from Products.CMFPlone.PasswordResetTool import InvalidRequestError
from Products.CMFPlone.RegistrationTool import get_member_by_login_name
+from zExceptions import BadRequest
from zExceptions import Forbidden
from zExceptions import HTTPNotAcceptable as NotAcceptable
from zope.component import getAdapter
@@ -136,12 +137,19 @@ def reply(self):
portal = getSite()
- if self.request.getHeader("Content-Type") == "text/csv":
+ if form := self.request.form:
+ if not form.get("file"):
+ raise BadRequest("No file uploaded")
+
+ file = form["file"]
+ if file.headers.get("Content-Type") not in ("text/csv", "application/csv"):
+ raise BadRequest("Uploaded file is not a valid CSV file")
if len(self.params) > 0:
raise NotAcceptable(_(""))
+
data = []
stream = io.TextIOWrapper(
- self.request.stdin,
+ file,
encoding="utf-8",
newline="",
)
From 9aa4d6b228976e4b5027238d8bec26ec191304c9 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Mon, 26 Jan 2026 19:33:06 +0100
Subject: [PATCH 18/38] Send file through body in users test
---
.../http-examples/users_add_csv_format.resp | 67 ++++++++++++-------
.../restapi/tests/test_services_users.py | 22 +++---
2 files changed, 52 insertions(+), 37 deletions(-)
diff --git a/src/plone/restapi/tests/http-examples/users_add_csv_format.resp b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
index fe75cbad1b..d38b078c3c 100644
--- a/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
@@ -1,28 +1,43 @@
-HTTP/1.1 201 Created
+HTTP/1.1 500 Internal Server Error
Content-Type: application/json
-Location: http://localhost:55001/plone/@users/noamchomsky
-[
- {
- "@id": "http://localhost:55001/plone/@users/noamchomsky",
- "description": null,
- "email": "noam.chomsky@example.com",
- "fullname": "Noam Avran Chomsky",
- "groups": {
- "@id": "http://localhost:55001/plone/@users",
- "items": [
- {
- "id": "AuthenticatedUsers",
- "title": "AuthenticatedUsers"
- }
- ],
- "items_total": 1
- },
- "home_page": null,
- "id": "noamchomsky",
- "location": "Cambridge, MA",
- "portrait": null,
- "roles": [],
- "username": "noamchomsky"
- }
-]
+{
+ "context": "http://localhost:55001/plone",
+ "message": "'No JSON object could be decoded'",
+ "traceback": [
+ "File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/ZPublisher/WSGIPublisher.py\", line 181, in transaction_pubevents",
+ " yield",
+ "",
+ " File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/ZPublisher/WSGIPublisher.py\", line 390, in publish_module",
+ " response = _publish(request, new_mod_info)",
+ " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
+ "",
+ " File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/ZPublisher/WSGIPublisher.py\", line 284, in publish",
+ " result = mapply(obj,",
+ " ^^^^^^^^^^^",
+ "",
+ " File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/ZPublisher/mapply.py\", line 98, in mapply",
+ " return debug(object, args, context)",
+ " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
+ "",
+ " File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/ZPublisher/WSGIPublisher.py\", line 68, in call_object",
+ " return obj(*args)",
+ " ^^^^^^^^^^",
+ "",
+ " File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/plone/rest/service.py\", line 21, in __call__",
+ " return self.render()",
+ " ^^^^^^^^^^^^^",
+ "",
+ " File \"/Users/jonaspiterek/workspace/plone.restapi/src/plone/restapi/services/__init__.py\", line 19, in render",
+ " content = self.reply()",
+ " ^^^^^^^^^^^^",
+ "",
+ " File \"/Users/jonaspiterek/workspace/plone.restapi/src/plone/restapi/services/users/add.py\", line 169, in reply",
+ " data = json_body(self.request)",
+ " ^^^^^^^^^^^^^^^^^^^^^^^",
+ "",
+ " File \"/Users/jonaspiterek/workspace/plone.restapi/src/plone/restapi/deserializer/__init__.py\", line 14, in json_body",
+ " raise DeserializationError(\"No JSON object could be decoded\")"
+ ],
+ "type": "DeserializationError"
+}
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index 3e695a3cd4..2aa6d9e40e 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -378,17 +378,21 @@ def test_add_user_with_uuid_as_userid_enabled(self):
self.assertEqual("howard.zinn@example.com", user.getProperty("email"))
def test_add_users_via_csv(self):
+ """Test POST /@users for CSV upload"""
+
+ content = b"username,email,fullname,description,home_page,password\njdoe,jdoe@example.com,John Doe,Software developer from Berlin,https://jdoe.dev,pass1234\nasmith,asmith@example.com,Alice Smith,Frontend engineer and designer,https://alice.design,alicePwd!\nbwayne,bwayne@example.com,Bruce Wayne,Tech entrepreneur,https://wayneenterprises.com,batman42\n"
+
resp = self.api_session.post(
"/@users",
- data=b"username,email,fullname,description,home_page,password\njdoe,jdoe@example.com,John Doe,Software developer from Berlin,https://jdoe.dev,pass1234\nasmith,asmith@example.com,Alice Smith,Frontend engineer and designer,https://alice.design,alicePwd!\nbwayne,bwayne@example.com,Bruce Wayne,Tech entrepreneur,https://wayneenterprises.com,batman42\nckent,ckent@example.com,Clark Kent,Journalist and blogger,https://dailyplanet.blog,superman\ndprince,dprince@example.com,Diana Prince,Product manager,https://diana.pm,amazon123\npparker,pparker@example.com,Peter Parker,Photography enthusiast,https://pphotos.net,sp1der\ntstark,tstark@example.com,Tony Stark,Inventor and engineer,https://stark.io,ironman\nsrogers,srogers@example.com,Steve Rogers,Team lead,https://leadership.dev,shield\nnromanoff,nromanoff@example.com,Natasha Romanoff,Security consultant,https://securelife.org,blackwidow\nbwilson,bwilson@example.com,Bob Wilson,DevOps specialist,https://devops.bob,ops2024\nemiller,emiller@example.com,Emma Miller,QA engineer,https://qualityemma.com,test123\nrjohnson,rjohnson@example.com,Robert Johnson,Backend developer,https://rob.codes,backend!\nlwhite,lwhite@example.com,Linda White,UX researcher,https://uxlinda.com,research\nknguyen,knguyen@example.com,Kevin Nguyen,Mobile app developer,https://kevinapps.dev,android\nmgarcia,mgarcia@example.com,Maria Garcia,Data analyst,https://datamaria.io,stats99\nhlee,hlee@example.com,Hannah Lee,AI student,https://hannah.ai,mlfuture\njmartin,jmartin@example.com,James Martin,System administrator,https://sysadmin.blog,rootaccess\nslopez,slopez@example.com,Sofia Lopez,Content strategist,https://sofialopez.media,content1\ntanderson,tanderson@example.com,Tom Anderson,Network engineer,https://networks.tom,packet\nvpatel,vpatel@example.com,Vihaan Patel,Cloud architect,https://cloudvihaan.com,awsrocks\nowright,owright@example.com,Oliver Wright,Game developer,https://playoliver.dev,gamedev\nfmueller,fmueller@example.com,Felix M\xc3\xbcller,Java developer,https://felixjava.de,maven21\nakhan,akhan@example.com,Aisha Khan,IT consultant,https://aishakhan.tech,consult!\nychen,ychen@example.com,Yi Chen,Full-stack developer,https://yichen.dev,fullstack\nrpetrov,rpetrov@example.com,Roman Petrov,Security researcher,https://romansec.io,zeroTrust\n",
- headers={"Accept": "text/csv", "Content-Type": "text/csv"},
+ files={"file": ("users.csv", content, "text/csv")},
)
transaction.commit()
+ breakpoint()
self.assertEqual(resp.status_code, 201)
- dprince = api.user.get(username="dprince")
- self.assertEqual(dprince.getProperty("email"), "dprince@example.com")
- self.assertTrue(api.user.get_roles("dprince"), "Member")
+ jdoe = api.user.get(username="jdoe")
+ self.assertEqual(jdoe.getProperty("email"), "jdoe@example.com")
+ self.assertTrue(api.user.get_roles("jdoe"), "Member")
def test_get_user(self):
response = self.api_session.get("/@users/noam")
@@ -401,12 +405,8 @@ def test_get_user(self):
)
self.assertEqual("noam.chomsky@example.com", response.json().get("email"))
self.assertEqual("Noam Avram Chomsky", response.json().get("fullname"))
- self.assertEqual(
- "web.mit.edu/chomsky", response.json().get("home_page")
- ) # noqa
- self.assertEqual(
- "Professor of Linguistics", response.json().get("description")
- ) # noqa
+ self.assertEqual("web.mit.edu/chomsky", response.json().get("home_page")) # noqa
+ self.assertEqual("Professor of Linguistics", response.json().get("description")) # noqa
self.assertEqual("Cambridge, MA", response.json().get("location"))
def test_get_user_as_anonymous(self):
From f42f534aa5046e2c46fd942cbcb120e5e560a874 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Mon, 26 Jan 2026 19:33:46 +0100
Subject: [PATCH 19/38] Fix tests to expect an extra registry record
---
src/plone/restapi/tests/http-examples/registry_get_list.resp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/plone/restapi/tests/http-examples/registry_get_list.resp b/src/plone/restapi/tests/http-examples/registry_get_list.resp
index 128bb9dc92..322c43121f 100644
--- a/src/plone/restapi/tests/http-examples/registry_get_list.resp
+++ b/src/plone/restapi/tests/http-examples/registry_get_list.resp
@@ -423,5 +423,5 @@ Content-Type: application/json
"value": "The person that created an item"
}
],
- "items_total": 2999
+ "items_total": 2998
}
From 0efb0513295ad13f283fee5ed4a1ae4a07f45f3c Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Mon, 26 Jan 2026 19:39:38 +0100
Subject: [PATCH 20/38] Remove breakpoint
---
src/plone/restapi/tests/test_services_users.py | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index 2aa6d9e40e..d599d20cbe 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -388,7 +388,6 @@ def test_add_users_via_csv(self):
)
transaction.commit()
- breakpoint()
self.assertEqual(resp.status_code, 201)
jdoe = api.user.get(username="jdoe")
self.assertEqual(jdoe.getProperty("email"), "jdoe@example.com")
@@ -405,8 +404,12 @@ def test_get_user(self):
)
self.assertEqual("noam.chomsky@example.com", response.json().get("email"))
self.assertEqual("Noam Avram Chomsky", response.json().get("fullname"))
- self.assertEqual("web.mit.edu/chomsky", response.json().get("home_page")) # noqa
- self.assertEqual("Professor of Linguistics", response.json().get("description")) # noqa
+ self.assertEqual(
+ "web.mit.edu/chomsky", response.json().get("home_page")
+ ) # noqa
+ self.assertEqual(
+ "Professor of Linguistics", response.json().get("description")
+ ) # noqa
self.assertEqual("Cambridge, MA", response.json().get("location"))
def test_get_user_as_anonymous(self):
From 763fa6ab53ba1f3ac60e0b09e47dd1e4d152d178 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Tue, 27 Jan 2026 10:24:49 +0100
Subject: [PATCH 21/38] Fix tests by defaulting to dict in HypermediaBatch
---
src/plone/restapi/batching.py | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/plone/restapi/batching.py b/src/plone/restapi/batching.py
index 7d9bec01ba..8ca6a589fa 100644
--- a/src/plone/restapi/batching.py
+++ b/src/plone/restapi/batching.py
@@ -4,7 +4,6 @@
from plone.restapi.exceptions import DeserializationError
from urllib.parse import parse_qsl
from urllib.parse import urlencode
-from zExceptions import BadRequest
DEFAULT_BATCH_SIZE = 25
@@ -16,8 +15,8 @@ def __init__(self, request, results):
try:
data = json_body(request)
- except DeserializationError as e:
- raise BadRequest(e)
+ except DeserializationError:
+ data = {}
self.b_start = parse_int(data, "b_start", False) or parse_int(
self.request.form, "b_start", 0
)
From b57ee1d664a56207fee8ff0c03f02fade2ceec0f Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Tue, 27 Jan 2026 10:49:38 +0100
Subject: [PATCH 22/38] Fix tests to expect an extra registry entry
---
src/plone/restapi/tests/http-examples/registry_get_list.resp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/plone/restapi/tests/http-examples/registry_get_list.resp b/src/plone/restapi/tests/http-examples/registry_get_list.resp
index 322c43121f..5af98dc5dc 100644
--- a/src/plone/restapi/tests/http-examples/registry_get_list.resp
+++ b/src/plone/restapi/tests/http-examples/registry_get_list.resp
@@ -423,5 +423,5 @@ Content-Type: application/json
"value": "The person that created an item"
}
],
- "items_total": 2998
+ "items_total": 3000
}
From 06a317d12cc39ae9c440392f800fe3ea69bcea35 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Tue, 27 Jan 2026 11:01:13 +0100
Subject: [PATCH 23/38] Fix users post http examples
---
.../http-examples/users_add_csv_format.req | 10 ++-
.../http-examples/users_add_csv_format.resp | 67 +++++++------------
src/plone/restapi/tests/test_documentation.py | 10 +--
3 files changed, 37 insertions(+), 50 deletions(-)
diff --git a/src/plone/restapi/tests/http-examples/users_add_csv_format.req b/src/plone/restapi/tests/http-examples/users_add_csv_format.req
index 0580448e15..4c2d8f378d 100644
--- a/src/plone/restapi/tests/http-examples/users_add_csv_format.req
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.req
@@ -1,7 +1,13 @@
POST /plone/@users HTTP/1.1
-Accept: text/csv
+Accept: application/json
Authorization: Basic YWRtaW46c2VjcmV0
-Content-Type: text/csv
+Content-Type: multipart/form-data; boundary=3ee316e66d1d8c0c2d25b3e464ae4fba
+--3ee316e66d1d8c0c2d25b3e464ae4fba
+Content-Disposition: form-data; name="file"; filename="users.csv"
+Content-Type: text/csv
+
id,username,fullname,email,roles,location,password
noam,noamchomsky,Noam Avran Chomsky,noam.chomsky@example.com,Contributor,"Cambridge, MA",password1234
+
+--3ee316e66d1d8c0c2d25b3e464ae4fba--
diff --git a/src/plone/restapi/tests/http-examples/users_add_csv_format.resp b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
index d38b078c3c..fe75cbad1b 100644
--- a/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
@@ -1,43 +1,28 @@
-HTTP/1.1 500 Internal Server Error
+HTTP/1.1 201 Created
Content-Type: application/json
+Location: http://localhost:55001/plone/@users/noamchomsky
-{
- "context": "http://localhost:55001/plone",
- "message": "'No JSON object could be decoded'",
- "traceback": [
- "File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/ZPublisher/WSGIPublisher.py\", line 181, in transaction_pubevents",
- " yield",
- "",
- " File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/ZPublisher/WSGIPublisher.py\", line 390, in publish_module",
- " response = _publish(request, new_mod_info)",
- " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
- "",
- " File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/ZPublisher/WSGIPublisher.py\", line 284, in publish",
- " result = mapply(obj,",
- " ^^^^^^^^^^^",
- "",
- " File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/ZPublisher/mapply.py\", line 98, in mapply",
- " return debug(object, args, context)",
- " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
- "",
- " File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/ZPublisher/WSGIPublisher.py\", line 68, in call_object",
- " return obj(*args)",
- " ^^^^^^^^^^",
- "",
- " File \"/Users/jonaspiterek/workspace/plone.restapi/.venv/lib/python3.12/site-packages/plone/rest/service.py\", line 21, in __call__",
- " return self.render()",
- " ^^^^^^^^^^^^^",
- "",
- " File \"/Users/jonaspiterek/workspace/plone.restapi/src/plone/restapi/services/__init__.py\", line 19, in render",
- " content = self.reply()",
- " ^^^^^^^^^^^^",
- "",
- " File \"/Users/jonaspiterek/workspace/plone.restapi/src/plone/restapi/services/users/add.py\", line 169, in reply",
- " data = json_body(self.request)",
- " ^^^^^^^^^^^^^^^^^^^^^^^",
- "",
- " File \"/Users/jonaspiterek/workspace/plone.restapi/src/plone/restapi/deserializer/__init__.py\", line 14, in json_body",
- " raise DeserializationError(\"No JSON object could be decoded\")"
- ],
- "type": "DeserializationError"
-}
+[
+ {
+ "@id": "http://localhost:55001/plone/@users/noamchomsky",
+ "description": null,
+ "email": "noam.chomsky@example.com",
+ "fullname": "Noam Avran Chomsky",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ }
+ ],
+ "items_total": 1
+ },
+ "home_page": null,
+ "id": "noamchomsky",
+ "location": "Cambridge, MA",
+ "portrait": null,
+ "roles": [],
+ "username": "noamchomsky"
+ }
+]
diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py
index 1ea665e161..d39904d79b 100644
--- a/src/plone/restapi/tests/test_documentation.py
+++ b/src/plone/restapi/tests/test_documentation.py
@@ -1098,13 +1098,9 @@ def test_documentation_users_csv_format_add(self):
content = b'id,username,fullname,email,roles,location,password\r\nnoam,noamchomsky,Noam Avran Chomsky,noam.chomsky@example.com,Contributor,"Cambridge, MA",password1234\r\n'
- headers = {
- "Accept": "text/csv",
- "Authorization": "Basic YWRtaW46c2VjcmV0",
- "Content-Type": "text/csv",
- }
-
- response = self.api_session.post(url, headers=headers, data=content)
+ response = self.api_session.post(
+ url, files={"file": ("users.csv", content, "text/csv")}
+ )
save_request_and_response_for_docs("users_add_csv_format", response)
def test_documentation_users_update(self):
From c1ed205771354a4ffbbed77fbd6fcb30ef4587a0 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Tue, 27 Jan 2026 11:27:40 +0100
Subject: [PATCH 24/38] Use fixed boundary to make the producing .req & .resp
files deterministic
---
.../http-examples/users_add_csv_format.req | 6 ++---
src/plone/restapi/tests/test_documentation.py | 22 +++++++++++++++++--
2 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/src/plone/restapi/tests/http-examples/users_add_csv_format.req b/src/plone/restapi/tests/http-examples/users_add_csv_format.req
index 4c2d8f378d..5e1a83c73b 100644
--- a/src/plone/restapi/tests/http-examples/users_add_csv_format.req
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.req
@@ -1,13 +1,13 @@
POST /plone/@users HTTP/1.1
Accept: application/json
Authorization: Basic YWRtaW46c2VjcmV0
-Content-Type: multipart/form-data; boundary=3ee316e66d1d8c0c2d25b3e464ae4fba
+Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
---3ee316e66d1d8c0c2d25b3e464ae4fba
+------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="users.csv"
Content-Type: text/csv
id,username,fullname,email,roles,location,password
noam,noamchomsky,Noam Avran Chomsky,noam.chomsky@example.com,Contributor,"Cambridge, MA",password1234
---3ee316e66d1d8c0c2d25b3e464ae4fba--
+------WebKitFormBoundary7MA4YWxkTrZu0gW--
diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py
index d39904d79b..4308fb81a5 100644
--- a/src/plone/restapi/tests/test_documentation.py
+++ b/src/plone/restapi/tests/test_documentation.py
@@ -1097,10 +1097,28 @@ def test_documentation_users_csv_format_add(self):
url = f"{self.portal.absolute_url()}/@users"
content = b'id,username,fullname,email,roles,location,password\r\nnoam,noamchomsky,Noam Avran Chomsky,noam.chomsky@example.com,Contributor,"Cambridge, MA",password1234\r\n'
+ csv_file = io.BytesIO(content)
+ csv_file.name = "users.csv"
- response = self.api_session.post(
- url, files={"file": ("users.csv", content, "text/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("users_add_csv_format", response)
def test_documentation_users_update(self):
From a80306ef7379665cd50a80a0c6dfd3fbc28dd722 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Tue, 27 Jan 2026 17:02:54 +0100
Subject: [PATCH 25/38] Add docs for POST request
---
docs/source/endpoints/users.md | 41 ++++++++++++++++++++++++++++++++++
1 file changed, 41 insertions(+)
diff --git a/docs/source/endpoints/users.md b/docs/source/endpoints/users.md
index fe4e02c843..6747a14222 100644
--- a/docs/source/endpoints/users.md
+++ b/docs/source/endpoints/users.md
@@ -147,6 +147,47 @@ The `Location` header contains the URL of the newly created user, and the resour
If no roles have been specified, then a `Member` role is added as a sensible default.
+### Create users via CSV
+
+To create a new user from a CSV file, send a `POST` request to the `/@users` endpoint with a `multipart/form-data` body containing a CSV file with the user details.
+The endpoint expects the CSV file to be under a "file" part of the `Content-Disposition: multipart/form-data`:
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/users_add_csv_format.req
+```
+
+The CSV file's first line is reserved for the header. Possible columns include:
+
+| Column | Type | Example |
+|-----------------|--------|---------------------------------|
+| `id` (required) | string | jdoe |
+| `username` | string | jdoe |
+| `fullname` | string | John Doe |
+| `description` | string | Software Developer from Berlin. |
+| `email` | string | jdoe@example.com |
+| `roles` | list | "Member, Contributor" |
+| `groups` | list | AuthenticatedUsers |
+| `location` | string | Berlin, DE |
+| `home_page` | string | jdoe.dev |
+| `password` | string | pwd1234 |
+
+```{note}
+If you want a user to have more that one role, note that you have to put the roles in quotes (see table above).
+```
+
+Example of a minimal CSV file:
+
+```
+id,fullname,description,email
+jdoe,John Doe,Software Developer from Berlin,jdoe@example.com
+```
+
+Response:
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/users_add_csv_format.resp
+:language: http
+```
## Read User
From 317ed83e221258a0216993d419564219dc1c26bd Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Wed, 28 Jan 2026 09:10:15 +0100
Subject: [PATCH 26/38] Apply suggestions from code review
Co-authored-by: Steve Piercy
---
docs/source/endpoints/users.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/source/endpoints/users.md b/docs/source/endpoints/users.md
index 6747a14222..faeafc3fce 100644
--- a/docs/source/endpoints/users.md
+++ b/docs/source/endpoints/users.md
@@ -168,12 +168,13 @@ The CSV file's first line is reserved for the header. Possible columns include:
| `email` | string | jdoe@example.com |
| `roles` | list | "Member, Contributor" |
| `groups` | list | AuthenticatedUsers |
-| `location` | string | Berlin, DE |
+| `location` | string | "Berlin, DE" |
| `home_page` | string | jdoe.dev |
| `password` | string | pwd1234 |
```{note}
-If you want a user to have more that one role, note that you have to put the roles in quotes (see table above).
+When a user has more than one role, put the roles in quotes, as shown in the table above.
+Additionally, values that contain commas should be placed in quotes.
```
Example of a minimal CSV file:
From c02e49e6de42ac99f37997b4081ce62196edad5c Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Wed, 28 Jan 2026 10:26:36 +0100
Subject: [PATCH 27/38] Move resp below req & update CSV example
---
docs/source/endpoints/users.md | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/docs/source/endpoints/users.md b/docs/source/endpoints/users.md
index faeafc3fce..0dabb65243 100644
--- a/docs/source/endpoints/users.md
+++ b/docs/source/endpoints/users.md
@@ -157,6 +157,12 @@ The endpoint expects the CSV file to be under a "file" part of the `Content-Disp
:request: ../../../src/plone/restapi/tests/http-examples/users_add_csv_format.req
```
+Response:
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/users_add_csv_format.resp
+:language: http
+```
+
The CSV file's first line is reserved for the header. Possible columns include:
| Column | Type | Example |
@@ -177,17 +183,11 @@ When a user has more than one role, put the roles in quotes, as shown in the tab
Additionally, values that contain commas should be placed in quotes.
```
-Example of a minimal CSV file:
+Example of a CSV file with quoted values:
```
-id,fullname,description,email
-jdoe,John Doe,Software Developer from Berlin,jdoe@example.com
-```
-
-Response:
-
-```{literalinclude} ../../../src/plone/restapi/tests/http-examples/users_add_csv_format.resp
-:language: http
+id,fullname,description,email,roles
+jdoe,John Doe,Software Developer from Berlin,jdoe@example.com,"Member, Contributor"
```
## Read User
From 713f3ecff17e199dfc44677a2cefaa96a0056d01 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Thu, 29 Jan 2026 14:29:28 +0100
Subject: [PATCH 28/38] Fix roles not being applied & update tests
---
src/plone/restapi/services/users/add.py | 8 +++
.../http-examples/users_add_csv_format.req | 6 +-
.../http-examples/users_add_csv_format.resp | 71 ++++++++++++++++---
src/plone/restapi/tests/test_documentation.py | 2 +-
.../restapi/tests/test_services_users.py | 2 +-
5 files changed, 75 insertions(+), 14 deletions(-)
diff --git a/src/plone/restapi/services/users/add.py b/src/plone/restapi/services/users/add.py
index 0b0a2860b7..87c17d6659 100644
--- a/src/plone/restapi/services/users/add.py
+++ b/src/plone/restapi/services/users/add.py
@@ -156,6 +156,14 @@ def reply(self):
try:
reader = csv.DictReader(stream)
for row in reader:
+ # convert to lists
+ for key in ("roles", "groups"):
+ if row.get(key):
+ row[key] = [r.strip() for r in row[key].split(",")]
+ # remove empty values
+ for key in list(row.keys()):
+ if not row[key]:
+ del row[key]
# validate important data
self.validate_input_data(portal, row)
data.append(row)
diff --git a/src/plone/restapi/tests/http-examples/users_add_csv_format.req b/src/plone/restapi/tests/http-examples/users_add_csv_format.req
index 5e1a83c73b..60754b2455 100644
--- a/src/plone/restapi/tests/http-examples/users_add_csv_format.req
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.req
@@ -7,7 +7,9 @@ Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0g
Content-Disposition: form-data; name="file"; filename="users.csv"
Content-Type: text/csv
-id,username,fullname,email,roles,location,password
-noam,noamchomsky,Noam Avran Chomsky,noam.chomsky@example.com,Contributor,"Cambridge, MA",password1234
+username,email,fullname,description,roles,home_page,password
+jdoe,jdoe@example.com,John Doe,Software developer from Berlin,"Member, Contributor",https://jdoe.dev,pass1234
+asmith,asmith@example.com,Alice Smith,Frontend engineer and designer,Member,https://alice.design,alicePwd!
+bwayne,bwayne@example.com,Bruce Wayne,Tech entrepreneur,,https://wayneenterprises.com,batman42
------WebKitFormBoundary7MA4YWxkTrZu0gW--
diff --git a/src/plone/restapi/tests/http-examples/users_add_csv_format.resp b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
index fe75cbad1b..d4419d8365 100644
--- a/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
@@ -1,13 +1,13 @@
HTTP/1.1 201 Created
Content-Type: application/json
-Location: http://localhost:55001/plone/@users/noamchomsky
+Location: http://localhost:55001/plone/@users/bwayne
[
{
- "@id": "http://localhost:55001/plone/@users/noamchomsky",
- "description": null,
- "email": "noam.chomsky@example.com",
- "fullname": "Noam Avran Chomsky",
+ "@id": "http://localhost:55001/plone/@users/jdoe",
+ "description": "Software developer from Berlin",
+ "email": "jdoe@example.com",
+ "fullname": "John Doe",
"groups": {
"@id": "http://localhost:55001/plone/@users",
"items": [
@@ -18,11 +18,62 @@ Location: http://localhost:55001/plone/@users/noamchomsky
],
"items_total": 1
},
- "home_page": null,
- "id": "noamchomsky",
- "location": "Cambridge, MA",
+ "home_page": "https://jdoe.dev",
+ "id": "jdoe",
+ "location": null,
"portrait": null,
- "roles": [],
- "username": "noamchomsky"
+ "roles": [
+ "Contributor",
+ "Member"
+ ],
+ "username": "jdoe"
+ },
+ {
+ "@id": "http://localhost:55001/plone/@users/asmith",
+ "description": "Frontend engineer and designer",
+ "email": "asmith@example.com",
+ "fullname": "Alice Smith",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ }
+ ],
+ "items_total": 1
+ },
+ "home_page": "https://alice.design",
+ "id": "asmith",
+ "location": null,
+ "portrait": null,
+ "roles": [
+ "Member"
+ ],
+ "username": "asmith"
+ },
+ {
+ "@id": "http://localhost:55001/plone/@users/bwayne",
+ "description": "Tech entrepreneur",
+ "email": "bwayne@example.com",
+ "fullname": "Bruce Wayne",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ }
+ ],
+ "items_total": 1
+ },
+ "home_page": "https://wayneenterprises.com",
+ "id": "bwayne",
+ "location": null,
+ "portrait": null,
+ "roles": [
+ "Member"
+ ],
+ "username": "bwayne"
}
]
diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py
index 4308fb81a5..2f2de55d81 100644
--- a/src/plone/restapi/tests/test_documentation.py
+++ b/src/plone/restapi/tests/test_documentation.py
@@ -1096,7 +1096,7 @@ def test_documentation_users_add(self):
def test_documentation_users_csv_format_add(self):
url = f"{self.portal.absolute_url()}/@users"
- content = b'id,username,fullname,email,roles,location,password\r\nnoam,noamchomsky,Noam Avran Chomsky,noam.chomsky@example.com,Contributor,"Cambridge, MA",password1234\r\n'
+ content = b'username,email,fullname,description,roles,home_page,password\r\njdoe,jdoe@example.com,John Doe,Software developer from Berlin,"Member, Contributor",https://jdoe.dev,pass1234\nasmith,asmith@example.com,Alice Smith,Frontend engineer and designer,Member,https://alice.design,alicePwd!\r\nbwayne,bwayne@example.com,Bruce Wayne,Tech entrepreneur,,https://wayneenterprises.com,batman42\r\n'
csv_file = io.BytesIO(content)
csv_file.name = "users.csv"
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index d599d20cbe..e357478ed9 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -380,7 +380,7 @@ def test_add_user_with_uuid_as_userid_enabled(self):
def test_add_users_via_csv(self):
"""Test POST /@users for CSV upload"""
- content = b"username,email,fullname,description,home_page,password\njdoe,jdoe@example.com,John Doe,Software developer from Berlin,https://jdoe.dev,pass1234\nasmith,asmith@example.com,Alice Smith,Frontend engineer and designer,https://alice.design,alicePwd!\nbwayne,bwayne@example.com,Bruce Wayne,Tech entrepreneur,https://wayneenterprises.com,batman42\n"
+ content = b'username,email,fullname,description,roles,home_page,password\njdoe,jdoe@example.com,John Doe,Software developer from Berlin,"Member, Contributor",https://jdoe.dev,pass1234\nasmith,asmith@example.com,Alice Smith,Frontend engineer and designer,,https://alice.design,alicePwd!\nbwayne,bwayne@example.com,Bruce Wayne,Tech entrepreneur,,https://wayneenterprises.com,batman42\n'
resp = self.api_session.post(
"/@users",
From 4caec07d61d4d724b2660c9ea8b67657a31a71c7 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Tue, 3 Feb 2026 16:33:21 +0100
Subject: [PATCH 29/38] Apply suggestions from code review
Co-authored-by: David Glick
---
docs/source/endpoints/users.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/source/endpoints/users.md b/docs/source/endpoints/users.md
index 0dabb65243..14bdec19d0 100644
--- a/docs/source/endpoints/users.md
+++ b/docs/source/endpoints/users.md
@@ -150,7 +150,7 @@ If no roles have been specified, then a `Member` role is added as a sensible def
### Create users via CSV
To create a new user from a CSV file, send a `POST` request to the `/@users` endpoint with a `multipart/form-data` body containing a CSV file with the user details.
-The endpoint expects the CSV file to be under a "file" part of the `Content-Disposition: multipart/form-data`:
+The endpoint expects a request body with `Content-Type: multipart/form-data` including a file upload named `file`.
```{eval-rst}
.. http:example:: curl httpie python-requests
From 5e5275e5141142da1aaa9775a232569ed58250c4 Mon Sep 17 00:00:00 2001
From: David Glick
Date: Wed, 4 Feb 2026 20:27:17 -0800
Subject: [PATCH 30/38] Update docs/source/endpoints/users.md
Co-authored-by: jnptk <110389276+jnptk@users.noreply.github.com>
---
docs/source/endpoints/users.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/source/endpoints/users.md b/docs/source/endpoints/users.md
index 14bdec19d0..6e1ba8d6d2 100644
--- a/docs/source/endpoints/users.md
+++ b/docs/source/endpoints/users.md
@@ -149,7 +149,7 @@ If no roles have been specified, then a `Member` role is added as a sensible def
### Create users via CSV
-To create a new user from a CSV file, send a `POST` request to the `/@users` endpoint with a `multipart/form-data` body containing a CSV file with the user details.
+To create new users from a CSV file, send a `POST` request to the `/@users` endpoint.
The endpoint expects a request body with `Content-Type: multipart/form-data` including a file upload named `file`.
```{eval-rst}
From 5c15ca7a0ae0dc369453f54d2b88e956d69f1a8d Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Tue, 3 Feb 2026 17:20:49 +0100
Subject: [PATCH 31/38] Error message
---
src/plone/restapi/services/users/add.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/plone/restapi/services/users/add.py b/src/plone/restapi/services/users/add.py
index 87c17d6659..33c6c4b78d 100644
--- a/src/plone/restapi/services/users/add.py
+++ b/src/plone/restapi/services/users/add.py
@@ -14,7 +14,6 @@
from Products.CMFPlone.RegistrationTool import get_member_by_login_name
from zExceptions import BadRequest
from zExceptions import Forbidden
-from zExceptions import HTTPNotAcceptable as NotAcceptable
from zope.component import getAdapter
from zope.component import getMultiAdapter
from zope.component import queryMultiAdapter
@@ -145,7 +144,7 @@ def reply(self):
if file.headers.get("Content-Type") not in ("text/csv", "application/csv"):
raise BadRequest("Uploaded file is not a valid CSV file")
if len(self.params) > 0:
- raise NotAcceptable(_(""))
+ raise BadRequest(f"Unexpected path element '{'/'.join(self.params)}'")
data = []
stream = io.TextIOWrapper(
From d17e2eab327ea9afd976d3dfefeb5dba4b38c60c Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Fri, 6 Feb 2026 17:24:59 +0100
Subject: [PATCH 32/38] Merge Users & UsersGet
---
src/plone/restapi/services/users/get.py | 131 +++++++-----------
.../restapi/tests/test_services_users.py | 61 ++++----
2 files changed, 78 insertions(+), 114 deletions(-)
diff --git a/src/plone/restapi/services/users/get.py b/src/plone/restapi/services/users/get.py
index 1c378895aa..39842e6b6c 100644
--- a/src/plone/restapi/services/users/get.py
+++ b/src/plone/restapi/services/users/get.py
@@ -82,17 +82,26 @@ def isDefaultPortrait(value):
)
-class Users:
- def __init__(self, context, request, params):
+@implementer(IPublishTraverse)
+class UsersGet(Service):
+ """Get users."""
+
+ def __init__(self, context, request):
+ super().__init__(context, request)
self.context = context
self.request = request
- self.params = params
+ self.params = []
portal = getSite()
self.portal_membership = getToolByName(portal, "portal_membership")
self.acl_users = getToolByName(portal, "acl_users")
self.query = parse_qs(self.request["QUERY_STRING"])
self.search_term = self.query.get("search", [""])[0]
+ def publishTraverse(self, request, name):
+ # Consume any path segments after /@users as parameters
+ self.params.append(name)
+ return self
+
@property
def _get_user_id(self):
if len(self.params) != 1:
@@ -196,8 +205,6 @@ def has_permission_to_access_user_info(self):
)
def reply(self):
- self.request.response.setStatus(200)
- self.request.response.setHeader("Content-Type", "application/json")
if len(self.query) > 0 and len(self.params) == 0:
query = self.query.get("query", "")
groups_filter = self.query.get("groups-filter:list", [])
@@ -223,17 +230,50 @@ def reply(self):
if len(self.params) == 0:
# Someone is asking for all users, check if they are authorized
+ result = []
if self.has_permission_to_enumerate():
- result = []
for user in self._get_users():
serializer = queryMultiAdapter(
(user, self.request), ISerializeToJson
)
result.append(serializer())
- return result, len(result)
else:
raise Unauthorized("You are not authorized to access this resource.")
+ if self.request.getHeader("Accept") == "text/csv":
+ file_descriptor, file_path = tempfile.mkstemp(
+ suffix=".csv", prefix="users_"
+ )
+ with open(file_path, "w") as stream:
+ csv_writer = writer(stream)
+ csv_writer.writerow(
+ ["id", "username", "fullname", "email", "roles", "groups"]
+ )
+
+ for user in result:
+ csv_writer.writerow(
+ [
+ user["id"],
+ user["username"],
+ user["fullname"],
+ user["email"],
+ ", ".join(user["roles"]),
+ ", ".join(g["id"] for g in user["groups"]["items"]),
+ ]
+ )
+ with open(file_path, "rb") as stream:
+ content = stream.read()
+
+ response = self.request.response
+ response.setHeader("Content-Type", "text/csv")
+ response.setHeader("Content-Length", len(content))
+ response.setHeader(
+ "Content-Disposition", "attachment; filename=users.csv"
+ )
+ return content
+ else:
+ return result, len(result)
+
# Some is asking one user, check if the logged in user is asking
# their own information or if they are a Manager
current_user_id = self.portal_membership.getAuthenticatedMember().getId()
@@ -250,86 +290,11 @@ def reply(self):
else:
raise Unauthorized("You are not authorized to access this resource.")
- def reply_root_csv(self):
- if len(self.params) > 0:
- raise BadRequest("You may not request a CSV reply for a specific user.")
-
- if self.has_permission_to_enumerate():
- file_descriptor, file_path = tempfile.mkstemp(
- suffix=".csv", prefix="users_"
- )
- with open(file_path, "w") as stream:
- csv_writer = writer(stream)
- csv_writer.writerow(
- ["id", "username", "fullname", "email", "roles", "groups"]
- )
-
- for user in self._get_users():
- serializer = queryMultiAdapter(
- (user, self.request), ISerializeToJson
- )
- user = serializer()
- csv_writer.writerow(
- [
- user["id"],
- user["username"],
- user["fullname"],
- user["email"],
- ", ".join(user["roles"]),
- ", ".join(g["id"] for g in user["groups"]["items"]),
- ]
- )
- with open(file_path, "rb") as stream:
- content = stream.read()
- else:
- raise Unauthorized("You are not authorized to access this resource.")
-
- response = self.request.response
- response.setHeader("Content-Type", "text/csv")
- response.setHeader("Content-Length", len(content))
- response.setHeader("Content-Disposition", "attachment; filename=users.csv")
- return content
-
- def __call__(self, expand=False):
- result = {"users": {"@id": f"{self.context.absolute_url()}/@users"}}
- if not expand:
- return result
- if self.request.getHeader("Accept") == "text/csv":
- result["users"]["items"] = self.reply_root_csv()
- return result
- else:
- if len(self.params) > 0:
- result["users"] = self.reply()
- return result
- items, items_total = self.reply()
-
- result["users"]["items"] = items
- result["users"]["items_total"] = items_total
- return result
-
-
-@implementer(IPublishTraverse)
-class UsersGet(Service):
- """Get users."""
-
- def __init__(self, context, request):
- super().__init__(context, request)
- self.params = []
-
- def publishTraverse(self, request, name):
- # Consume any path segments after /@users as parameters
- self.params.append(name)
- return self
-
- def reply(self):
- users = Users(self.context, self.request, self.params)
- return users(expand=True)["users"]
-
def render(self):
self.check_permission()
content = self.reply()
if self.request.getHeader("Accept") == "text/csv":
- return content["items"]
+ return content
if content is not _no_content_marker:
return json.dumps(
content, indent=2, sort_keys=True, separators=(", ", ": ")
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index e357478ed9..6c6a5b55d0 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -7,7 +7,6 @@
from plone.app.testing import TEST_USER_ID
from plone.app.testing import TEST_USER_PASSWORD
from plone.restapi.bbb import ISecuritySchema
-from plone.restapi.services.users.get import Users
from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
from plone.restapi.testing import RelativeSession
from Products.CMFCore.permissions import SetOwnPassword
@@ -1349,44 +1348,44 @@ def test_user_with_datetime(self):
# Not testable via the service, hence unittest
- def test_get_users_filtering(self):
- class MockUser:
- def __init__(self, userid):
- self.userid = userid
+ # def test_get_users_filtering(self):
+ # class MockUser:
+ # def __init__(self, userid):
+ # self.userid = userid
- def getProperty(self, key, default=None):
- return "Full Name " + self.userid
+ # def getProperty(self, key, default=None):
+ # return "Full Name " + self.userid
- def getUserName(self):
- return self.userid
+ # def getUserName(self):
+ # return self.userid
- class MockAclUsers:
- def searchUsers(self, **kw):
- return [
- {"userid": "user2"},
- {"userid": "user1"},
- {"userid": "NONEUSER"},
- ]
+ # class MockAclUsers:
+ # def searchUsers(self, **kw):
+ # return [
+ # {"userid": "user2"},
+ # {"userid": "user1"},
+ # {"userid": "NONEUSER"},
+ # ]
- class MockPortalMembership:
- def getMemberById(self, userid):
- if userid == "NONEUSER":
- return None
- return MockUser(userid)
+ # class MockPortalMembership:
+ # def getMemberById(self, userid):
+ # if userid == "NONEUSER":
+ # return None
+ # return MockUser(userid)
- # Create Users instance *without* calling its __init__
- users = Users.__new__(Users)
+ # # Create Users instance *without* calling its __init__
+ # users = Users.__new__(Users)
- # Inject only what _get_users actually needs
- users.acl_users = MockAclUsers()
- users.portal_membership = MockPortalMembership()
+ # # Inject only what _get_users actually needs
+ # users.acl_users = MockAclUsers()
+ # users.portal_membership = MockPortalMembership()
- result = users._get_users(foo="bar")
+ # result = users._get_users(foo="bar")
- # Sorted by normalized fullname; None users filtered out
- self.assertEqual(len(result), 2)
- self.assertEqual(result[0].userid, "user1")
- self.assertEqual(result[1].userid, "user2")
+ # # Sorted by normalized fullname; None users filtered out
+ # self.assertEqual(len(result), 2)
+ # self.assertEqual(result[0].userid, "user1")
+ # self.assertEqual(result[1].userid, "user2")
def test_siteadm_not_update_manager(self):
self.set_siteadm()
From c7ab8341efd7caa1ea322d214ff1677c1ab258d7 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Fri, 6 Feb 2026 17:25:20 +0100
Subject: [PATCH 33/38] Add changelog entry for HypermediaBatch fix
---
news/+hypermedia_batch.bugfix | 1 +
1 file changed, 1 insertion(+)
create mode 100644 news/+hypermedia_batch.bugfix
diff --git a/news/+hypermedia_batch.bugfix b/news/+hypermedia_batch.bugfix
new file mode 100644
index 0000000000..9f015c90f6
--- /dev/null
+++ b/news/+hypermedia_batch.bugfix
@@ -0,0 +1 @@
+Fix `HypermediaBatch` assuming a request always has a JSON body. @jnptk
From 95b821fb8953d0475d2135be87a60539a9b717b1 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Mon, 9 Feb 2026 11:39:57 +0100
Subject: [PATCH 34/38] Create CSV in-memory instead of temporary on disk
---
src/plone/restapi/services/users/get.py | 56 ++++++++++++++-----------
1 file changed, 31 insertions(+), 25 deletions(-)
diff --git a/src/plone/restapi/services/users/get.py b/src/plone/restapi/services/users/get.py
index 39842e6b6c..55bc1797c0 100644
--- a/src/plone/restapi/services/users/get.py
+++ b/src/plone/restapi/services/users/get.py
@@ -29,8 +29,8 @@
from zope.interface import implementer
from zope.publisher.interfaces import IPublishTraverse
+import io
import json
-import tempfile
DEFAULT_SEARCH_RESULTS_LIMIT = 25
@@ -205,6 +205,8 @@ def has_permission_to_access_user_info(self):
)
def reply(self):
+ result = {"@id": f"{self.context.absolute_url()}/@users"}
+
if len(self.query) > 0 and len(self.params) == 0:
query = self.query.get("query", "")
groups_filter = self.query.get("groups-filter:list", [])
@@ -214,13 +216,16 @@ def reply(self):
users = self._get_filtered_users(
query, groups_filter, self.search_term, limit
)
- result = []
+ items = []
for user in users:
serializer = queryMultiAdapter(
(user, self.request), ISerializeToJson
)
- result.append(serializer())
- return result, len(result)
+ items.append(serializer())
+
+ result["items"] = items
+ result["items_total"] = len(items)
+ return result
else:
raise Unauthorized(
"You are not authorized to access this resource."
@@ -230,27 +235,23 @@ def reply(self):
if len(self.params) == 0:
# Someone is asking for all users, check if they are authorized
- result = []
if self.has_permission_to_enumerate():
+ items = []
for user in self._get_users():
serializer = queryMultiAdapter(
(user, self.request), ISerializeToJson
)
- result.append(serializer())
- else:
- raise Unauthorized("You are not authorized to access this resource.")
+ items.append(serializer())
- if self.request.getHeader("Accept") == "text/csv":
- file_descriptor, file_path = tempfile.mkstemp(
- suffix=".csv", prefix="users_"
- )
- with open(file_path, "w") as stream:
+ if self.request.getHeader("Accept") == "text/csv":
+ buffer = io.BytesIO()
+ stream = io.TextIOWrapper(buffer, encoding="utf-8", newline="")
csv_writer = writer(stream)
csv_writer.writerow(
["id", "username", "fullname", "email", "roles", "groups"]
)
- for user in result:
+ for user in items:
csv_writer.writerow(
[
user["id"],
@@ -261,18 +262,23 @@ def reply(self):
", ".join(g["id"] for g in user["groups"]["items"]),
]
)
- with open(file_path, "rb") as stream:
- content = stream.read()
-
- response = self.request.response
- response.setHeader("Content-Type", "text/csv")
- response.setHeader("Content-Length", len(content))
- response.setHeader(
- "Content-Disposition", "attachment; filename=users.csv"
- )
- return content
+ stream.flush()
+ stream.detach()
+ content = buffer.getvalue()
+
+ response = self.request.response
+ response.setHeader("Content-Type", "text/csv")
+ response.setHeader("Content-Length", len(content))
+ response.setHeader(
+ "Content-Disposition", "attachment; filename=users.csv"
+ )
+ return content
+ else:
+ result["items"] = items
+ result["items_total"] = len(items)
+ return result
else:
- return result, len(result)
+ raise Unauthorized("You are not authorized to access this resource.")
# Some is asking one user, check if the logged in user is asking
# their own information or if they are a Manager
From f3e205134c0604049901263437ca1d4d394b01b7 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Tue, 10 Feb 2026 11:10:57 +0100
Subject: [PATCH 35/38] Remove location header for csv reply
---
src/plone/restapi/services/users/add.py | 11 ++++++-----
.../tests/http-examples/users_add_csv_format.resp | 1 -
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/plone/restapi/services/users/add.py b/src/plone/restapi/services/users/add.py
index 33c6c4b78d..d0d05ebc18 100644
--- a/src/plone/restapi/services/users/add.py
+++ b/src/plone/restapi/services/users/add.py
@@ -211,12 +211,12 @@ def reply(self):
if isinstance(data, list):
result = []
for i in data:
- user = self._add_user(i)
+ user = self._add_user(i, location=False)
result.append(user)
return result
return self._add_user(data)
- def _add_user(self, data):
+ def _add_user(self, data, location=True):
portal = getSite()
security = getAdapter(self.context, ISecuritySchema)
registration = getToolByName(portal, "portal_registration")
@@ -273,9 +273,10 @@ def _add_user(self, data):
if send_password_reset:
registration.registeredNotify(username)
self.request.response.setStatus(201)
- self.request.response.setHeader(
- "Location", portal.absolute_url() + "/@users/" + username
- )
+ if location:
+ self.request.response.setHeader(
+ "Location", portal.absolute_url() + "/@users/" + username
+ )
serializer = queryMultiAdapter((user, self.request), ISerializeToJson)
return serializer()
diff --git a/src/plone/restapi/tests/http-examples/users_add_csv_format.resp b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
index d4419d8365..8f03ecee8e 100644
--- a/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
@@ -1,6 +1,5 @@
HTTP/1.1 201 Created
Content-Type: application/json
-Location: http://localhost:55001/plone/@users/bwayne
[
{
From a9385782032ad29fc4ff499677f3abc4f4b2a477 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Tue, 10 Feb 2026 11:11:11 +0100
Subject: [PATCH 36/38] Fix tests
---
src/plone/restapi/services/users/get.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/plone/restapi/services/users/get.py b/src/plone/restapi/services/users/get.py
index 55bc1797c0..7b95ad69b6 100644
--- a/src/plone/restapi/services/users/get.py
+++ b/src/plone/restapi/services/users/get.py
@@ -206,6 +206,8 @@ def has_permission_to_access_user_info(self):
def reply(self):
result = {"@id": f"{self.context.absolute_url()}/@users"}
+ self.request.response.setStatus(200)
+ self.request.response.setHeader("Content-Type", "application/json")
if len(self.query) > 0 and len(self.params) == 0:
query = self.query.get("query", "")
From 5e96daea253814df972f442fe2227eae5a111aa7 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Tue, 10 Feb 2026 13:47:55 +0100
Subject: [PATCH 37/38] Rewrite test_get_users_filtering
---
.../restapi/tests/test_services_users.py | 85 ++++++++++---------
1 file changed, 45 insertions(+), 40 deletions(-)
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index 6c6a5b55d0..6772fdc6d8 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -1346,46 +1346,51 @@ def test_user_with_datetime(self):
self.assertIn("birthdate", response.json())
self.assertIn("registration_datetime", response.json())
- # Not testable via the service, hence unittest
-
- # def test_get_users_filtering(self):
- # class MockUser:
- # def __init__(self, userid):
- # self.userid = userid
-
- # def getProperty(self, key, default=None):
- # return "Full Name " + self.userid
-
- # def getUserName(self):
- # return self.userid
-
- # class MockAclUsers:
- # def searchUsers(self, **kw):
- # return [
- # {"userid": "user2"},
- # {"userid": "user1"},
- # {"userid": "NONEUSER"},
- # ]
-
- # class MockPortalMembership:
- # def getMemberById(self, userid):
- # if userid == "NONEUSER":
- # return None
- # return MockUser(userid)
-
- # # Create Users instance *without* calling its __init__
- # users = Users.__new__(Users)
-
- # # Inject only what _get_users actually needs
- # users.acl_users = MockAclUsers()
- # users.portal_membership = MockPortalMembership()
-
- # result = users._get_users(foo="bar")
-
- # # Sorted by normalized fullname; None users filtered out
- # self.assertEqual(len(result), 2)
- # self.assertEqual(result[0].userid, "user1")
- # self.assertEqual(result[1].userid, "user2")
+ def test_get_users_filtering(self):
+ self.api_session.post(
+ "/@users",
+ json={
+ "username": "user2",
+ "password": "secret123",
+ "email": "user2@example.com",
+ },
+ )
+
+ self.api_session.post(
+ "/@users",
+ json={
+ "username": "user1",
+ "password": "secret123",
+ "email": "user1@example.com",
+ },
+ )
+
+ self.api_session.post(
+ "/@users",
+ json={
+ "username": "foobar",
+ "password": "secret123",
+ "email": "foobar@example.com",
+ },
+ )
+ transaction.commit()
+
+ # Test with query parameter (passes id="user" as kwarg to searchUsers)
+ response = self.api_session.get("/@users?query=user")
+
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+
+ # Verify only users with "user" in username are returned
+ usernames = [item["username"] for item in data["items"]]
+ self.assertIn("user1", usernames)
+ self.assertIn("user2", usernames)
+ self.assertNotIn("foobar", usernames)
+
+ # Verify user1 comes before user2 (sorted by username since no fullname)
+ user1_index = usernames.index("user1")
+ user2_index = usernames.index("user2")
+ self.assertLess(user1_index, user2_index)
def test_siteadm_not_update_manager(self):
self.set_siteadm()
From 759a6b790836b341eff102e0bf1fe27756fc3ce6 Mon Sep 17 00:00:00 2001
From: jnptk <110389276+jnptk@users.noreply.github.com>
Date: Wed, 11 Feb 2026 14:04:09 +0100
Subject: [PATCH 38/38] Return dict with items array on user creation
---
src/plone/restapi/services/users/add.py | 2 +-
.../http-examples/users_add_csv_format.resp | 143 +++++++++---------
2 files changed, 74 insertions(+), 71 deletions(-)
diff --git a/src/plone/restapi/services/users/add.py b/src/plone/restapi/services/users/add.py
index d0d05ebc18..90a4bf19d6 100644
--- a/src/plone/restapi/services/users/add.py
+++ b/src/plone/restapi/services/users/add.py
@@ -213,7 +213,7 @@ def reply(self):
for i in data:
user = self._add_user(i, location=False)
result.append(user)
- return result
+ return {"items": result, "items_total": len(result)}
return self._add_user(data)
def _add_user(self, data, location=True):
diff --git a/src/plone/restapi/tests/http-examples/users_add_csv_format.resp b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
index 8f03ecee8e..30dd7779e4 100644
--- a/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
@@ -1,78 +1,81 @@
HTTP/1.1 201 Created
Content-Type: application/json
-[
- {
- "@id": "http://localhost:55001/plone/@users/jdoe",
- "description": "Software developer from Berlin",
- "email": "jdoe@example.com",
- "fullname": "John Doe",
- "groups": {
- "@id": "http://localhost:55001/plone/@users",
- "items": [
- {
- "id": "AuthenticatedUsers",
- "title": "AuthenticatedUsers"
- }
+{
+ "items": [
+ {
+ "@id": "http://localhost:55001/plone/@users/jdoe",
+ "description": "Software developer from Berlin",
+ "email": "jdoe@example.com",
+ "fullname": "John Doe",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ }
+ ],
+ "items_total": 1
+ },
+ "home_page": "https://jdoe.dev",
+ "id": "jdoe",
+ "location": null,
+ "portrait": null,
+ "roles": [
+ "Contributor",
+ "Member"
],
- "items_total": 1
+ "username": "jdoe"
},
- "home_page": "https://jdoe.dev",
- "id": "jdoe",
- "location": null,
- "portrait": null,
- "roles": [
- "Contributor",
- "Member"
- ],
- "username": "jdoe"
- },
- {
- "@id": "http://localhost:55001/plone/@users/asmith",
- "description": "Frontend engineer and designer",
- "email": "asmith@example.com",
- "fullname": "Alice Smith",
- "groups": {
- "@id": "http://localhost:55001/plone/@users",
- "items": [
- {
- "id": "AuthenticatedUsers",
- "title": "AuthenticatedUsers"
- }
+ {
+ "@id": "http://localhost:55001/plone/@users/asmith",
+ "description": "Frontend engineer and designer",
+ "email": "asmith@example.com",
+ "fullname": "Alice Smith",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ }
+ ],
+ "items_total": 1
+ },
+ "home_page": "https://alice.design",
+ "id": "asmith",
+ "location": null,
+ "portrait": null,
+ "roles": [
+ "Member"
],
- "items_total": 1
+ "username": "asmith"
},
- "home_page": "https://alice.design",
- "id": "asmith",
- "location": null,
- "portrait": null,
- "roles": [
- "Member"
- ],
- "username": "asmith"
- },
- {
- "@id": "http://localhost:55001/plone/@users/bwayne",
- "description": "Tech entrepreneur",
- "email": "bwayne@example.com",
- "fullname": "Bruce Wayne",
- "groups": {
- "@id": "http://localhost:55001/plone/@users",
- "items": [
- {
- "id": "AuthenticatedUsers",
- "title": "AuthenticatedUsers"
- }
+ {
+ "@id": "http://localhost:55001/plone/@users/bwayne",
+ "description": "Tech entrepreneur",
+ "email": "bwayne@example.com",
+ "fullname": "Bruce Wayne",
+ "groups": {
+ "@id": "http://localhost:55001/plone/@users",
+ "items": [
+ {
+ "id": "AuthenticatedUsers",
+ "title": "AuthenticatedUsers"
+ }
+ ],
+ "items_total": 1
+ },
+ "home_page": "https://wayneenterprises.com",
+ "id": "bwayne",
+ "location": null,
+ "portrait": null,
+ "roles": [
+ "Member"
],
- "items_total": 1
- },
- "home_page": "https://wayneenterprises.com",
- "id": "bwayne",
- "location": null,
- "portrait": null,
- "roles": [
- "Member"
- ],
- "username": "bwayne"
- }
-]
+ "username": "bwayne"
+ }
+ ],
+ "items_total": 3
+}