diff --git a/docs/source/endpoints/users.md b/docs/source/endpoints/users.md
index ced6498df..6e1ba8d6d 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 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
@@ -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
diff --git a/news/+hypermedia_batch.bugfix b/news/+hypermedia_batch.bugfix
new file mode 100644
index 000000000..9f015c90f
--- /dev/null
+++ b/news/+hypermedia_batch.bugfix
@@ -0,0 +1 @@
+Fix `HypermediaBatch` assuming a request always has a JSON body. @jnptk
diff --git a/news/+users_import_export.feature b/news/+users_import_export.feature
new file mode 100644
index 000000000..353d1f8c1
--- /dev/null
+++ b/news/+users_import_export.feature
@@ -0,0 +1 @@
+Add CSV import and export support to the @users endpoint. @jnptk
diff --git a/src/plone/restapi/batching.py b/src/plone/restapi/batching.py
index 7d9bec01b..8ca6a589f 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
)
diff --git a/src/plone/restapi/services/users/add.py b/src/plone/restapi/services/users/add.py
index 31fe6c599..90a4bf19d 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 BadRequest
+from zExceptions import Forbidden
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,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 "
@@ -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)
@@ -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)
@@ -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
)
@@ -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()
@@ -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
diff --git a/src/plone/restapi/services/users/configure.zcml b/src/plone/restapi/services/users/configure.zcml
index b3c48ddae..c54ca6674 100644
--- a/src/plone/restapi/services/users/configure.zcml
+++ b/src/plone/restapi/services/users/configure.zcml
@@ -12,6 +12,15 @@
name="@users"
/>
+
+
+
+
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(
@@ -193,6 +205,10 @@ 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", "")
groups_filter = self.query.get("groups-filter:list", [])
@@ -202,32 +218,69 @@ 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())
+ items.append(serializer())
+
+ result["items"] = items
+ result["items_total"] = len(items)
return result
else:
- self.request.response.setStatus(401)
- return
+ raise Unauthorized(
+ "You are not authorized to access this resource."
+ )
else:
raise BadRequest("Parameters supplied are not valid")
if len(self.params) == 0:
# Someone is asking for all users, check if they are authorized
if self.has_permission_to_enumerate():
- result = []
+ items = []
for user in self._get_users():
serializer = queryMultiAdapter(
(user, self.request), ISerializeToJson
)
- result.append(serializer())
- return result
+ items.append(serializer())
+
+ 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 items:
+ csv_writer.writerow(
+ [
+ user["id"],
+ user["username"],
+ user["fullname"],
+ user["email"],
+ ", ".join(user["roles"]),
+ ", ".join(g["id"] for g in user["groups"]["items"]),
+ ]
+ )
+ 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:
- self.request.response.setStatus(401)
- return
+ 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,13 +292,21 @@ 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(f"User ${self._get_user_id} does not exist.")
serializer = queryMultiAdapter((user, self.request), ISerializeToJson)
return serializer()
else:
- self.request.response.setStatus(401)
- return
+ raise Unauthorized("You are not authorized to access this resource.")
+
+ def render(self):
+ self.check_permission()
+ content = self.reply()
+ if self.request.getHeader("Accept") == "text/csv":
+ return content
+ if content is not _no_content_marker:
+ return json.dumps(
+ content, indent=2, sort_keys=True, separators=(", ", ": ")
+ )
@implementer(IPublishTraverse)
@@ -297,7 +358,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/http-examples/users.resp b/src/plone/restapi/tests/http-examples/users.resp
index a8e179eaf..a82e48807 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_add_csv_format.req b/src/plone/restapi/tests/http-examples/users_add_csv_format.req
new file mode 100644
index 000000000..60754b245
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.req
@@ -0,0 +1,15 @@
+POST /plone/@users HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
+
+------WebKitFormBoundary7MA4YWxkTrZu0gW
+Content-Disposition: form-data; name="file"; filename="users.csv"
+Content-Type: text/csv
+
+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
new file mode 100644
index 000000000..30dd7779e
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/users_add_csv_format.resp
@@ -0,0 +1,81 @@
+HTTP/1.1 201 Created
+Content-Type: application/json
+
+{
+ "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"
+ ],
+ "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"
+ }
+ ],
+ "items_total": 3
+}
diff --git a/src/plone/restapi/tests/http-examples/users_anonymous.resp b/src/plone/restapi/tests/http-examples/users_anonymous.resp
index f858d607a..61b9a20e5 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 f858d607a..61b9a20e5 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 7ea2e78da..1d6b72574 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 d69cc2364..014ac9551 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_get_csv_format.req b/src/plone/restapi/tests/http-examples/users_get_csv_format.req
new file mode 100644
index 000000000..59e6dca5d
--- /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 000000000..f354aa1a1
--- /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
+admin,admin,,,Manager,AuthenticatedUsers
+test_user_1_,test-user,,,Manager,AuthenticatedUsers
diff --git a/src/plone/restapi/tests/http-examples/users_searched.resp b/src/plone/restapi/tests/http-examples/users_searched.resp
index a7d26d834..61919733c 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 f858d607a..61b9a20e5 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 f858d607a..61b9a20e5 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"
+}
diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py
index 67b3620ba..2f2de55d8 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",
@@ -1074,6 +1093,34 @@ 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'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"
+
+ # 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):
properties = {
"email": "noam.chomsky@example.com",
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index ced72b0ee..6772fdc6d 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 UsersGet
from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
from plone.restapi.testing import RelativeSession
from Products.CMFCore.permissions import SetOwnPassword
@@ -35,7 +34,6 @@ def test_extract_media_type(self):
class TestUsersEndpoint(unittest.TestCase):
-
layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
def setUp(self):
@@ -129,12 +127,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 +151,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,15 +159,24 @@ 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_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\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):
response = self.api_session.post(
"/@users",
@@ -370,6 +376,22 @@ def test_add_user_with_uuid_as_userid_enabled(self):
self.assertEqual("howard.zinn@example.com", user.getUserName())
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,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",
+ files={"file": ("users.csv", content, "text/csv")},
+ )
+ transaction.commit()
+
+ self.assertEqual(resp.status_code, 201)
+ 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")
@@ -426,21 +448,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()["items"][0].get("email")
)
- self.assertEqual("noam.chomsky@example.com", response.json()[0].get("email"))
self.assertEqual(
- "Noam Avram Chomsky", response.json()[0].get("fullname")
+ "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(
@@ -1322,42 +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 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")
+ 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()