Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
e0c70e7
add the ability to export users as csv file
jnptk Jan 12, 2026
f9f3432
Add support for importing users from a CSV file
jnptk Jan 15, 2026
f543cf2
Add changelog entry
jnptk Jan 15, 2026
87aa161
Merge branch 'main' into users-import/export
jnptk Jan 15, 2026
f95684c
Don't make Users expandable, fixes the tests
jnptk Jan 16, 2026
4e2ccc4
Remove adapter from zcml
jnptk Jan 16, 2026
fbca829
Add error messages
jnptk Jan 16, 2026
ae0784f
Set correct response headers
jnptk Jan 16, 2026
0355fb1
Run tests
jnptk Jan 16, 2026
c8db031
Document `GET` method
jnptk Jan 16, 2026
45a7e4b
Document `POST` method
jnptk Jan 16, 2026
2e42fa5
Add tests for `GET` method
jnptk Jan 16, 2026
4eb58a1
Apply suggestions from code review
jnptk Jan 17, 2026
6f73ad2
Sort users by username if fullname is not set
jnptk Jan 22, 2026
ed1fb27
Explicitly return 201 on user creation
jnptk Jan 22, 2026
d90d483
Explicitly get user by username
jnptk Jan 23, 2026
e4e541a
Update users get examples
jnptk Jan 26, 2026
a91d62f
Get file through http body
jnptk Jan 26, 2026
9aa4d6b
Send file through body in users test
jnptk Jan 26, 2026
f42f534
Fix tests to expect an extra registry record
jnptk Jan 26, 2026
0efb051
Remove breakpoint
jnptk Jan 26, 2026
763fa6a
Fix tests by defaulting to dict in HypermediaBatch
jnptk Jan 27, 2026
b57ee1d
Fix tests to expect an extra registry entry
jnptk Jan 27, 2026
06a317d
Fix users post http examples
jnptk Jan 27, 2026
c1ed205
Use fixed boundary to make the producing .req & .resp files determini…
jnptk Jan 27, 2026
a80306e
Add docs for POST request
jnptk Jan 27, 2026
317ed83
Apply suggestions from code review
jnptk Jan 28, 2026
c02e49e
Move resp below req & update CSV example
jnptk Jan 28, 2026
713f3ec
Fix roles not being applied & update tests
jnptk Jan 29, 2026
c032ad4
Merge branch 'main' into users-import/export
jnptk Jan 30, 2026
4caec07
Apply suggestions from code review
jnptk Feb 3, 2026
5e5275e
Update docs/source/endpoints/users.md
davisagli Feb 5, 2026
5c15ca7
Error message
jnptk Feb 3, 2026
d17e2ea
Merge Users & UsersGet
jnptk Feb 6, 2026
c7ab834
Add changelog entry for HypermediaBatch fix
jnptk Feb 6, 2026
95b821f
Create CSV in-memory instead of temporary on disk
jnptk Feb 9, 2026
f3e2051
Remove location header for csv reply
jnptk Feb 10, 2026
a938578
Fix tests
jnptk Feb 10, 2026
5e96dae
Rewrite test_get_users_filtering
jnptk Feb 10, 2026
759a6b7
Return dict with items array on user creation
jnptk Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/source/endpoints/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 the 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

Expand Down Expand Up @@ -133,6 +147,48 @@ 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 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}
.. http:example:: curl httpie python-requests
: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 |
|-----------------|--------|---------------------------------|
| `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}
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 CSV file with quoted values:

```
id,fullname,description,email,roles
jdoe,John Doe,Software Developer from Berlin,jdoe@example.com,"Member, Contributor"
```

## Read User

Expand Down
1 change: 1 addition & 0 deletions news/+hypermedia_batch.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix `HypermediaBatch` assuming a request always has a JSON body. @jnptk
1 change: 1 addition & 0 deletions news/+users_import_export.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add CSV import and export support to the @users endpoint. @jnptk
5 changes: 2 additions & 3 deletions src/plone/restapi/batching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
Expand Down
84 changes: 66 additions & 18 deletions src/plone/restapi/services/users/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 BadRequest
from zExceptions import Forbidden
from zope.component import getAdapter
from zope.component import getMultiAdapter
from zope.component import queryMultiAdapter
Expand All @@ -21,6 +23,8 @@
from zope.interface import implementer
from zope.publisher.interfaces import IPublishTraverse

import csv
import io
import plone.protect.interfaces


Expand All @@ -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
Expand Down Expand Up @@ -131,12 +136,45 @@ 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 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 BadRequest(f"Unexpected path element '{'/'.join(self.params)}'")

data = []
stream = io.TextIOWrapper(
file,
encoding="utf-8",
newline="",
)
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)
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 "
Expand All @@ -152,11 +190,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)
Expand All @@ -173,6 +207,20 @@ def reply(self):
)
)

self.request.response.setStatus(201)
if isinstance(data, list):
result = []
for i in data:
user = self._add_user(i, location=False)
result.append(user)
return {"items": result, "items_total": len(result)}
return self._add_user(data)

def _add_user(self, data, location=True):
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)
Expand Down Expand Up @@ -205,7 +253,6 @@ def reply(self):
password = registration.generatePassword()
# Create user
try:
registration = getToolByName(portal, "portal_registration")
user = registration.addMember(
username, password, roles, properties=properties
)
Expand All @@ -219,17 +266,17 @@ 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)

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()

Expand Down Expand Up @@ -320,7 +367,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

Expand Down
18 changes: 18 additions & 0 deletions src/plone/restapi/services/users/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@
name="@users"
/>

<plone:service
method="GET"
accept="text/csv"
factory=".get.UsersGet"
for="plone.restapi.bbb.IPloneSiteRoot"
permission="plone.app.controlpanel.UsersAndGroups"
name="@users"
/>

<plone:service
method="PATCH"
factory=".update.UsersPatch"
Expand All @@ -28,6 +37,15 @@
name="@users"
/>

<plone:service
method="POST"
accept="text/csv"
factory=".add.UsersPost"
for="plone.restapi.bbb.IPloneSiteRoot"
permission="plone.app.controlpanel.UsersAndGroups"
name="@users"
/>

<plone:service
method="DELETE"
factory=".delete.UsersDelete"
Expand Down
Loading