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 +}