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