Repository: plone/plone.restapi (language: python)

Issue:
Add parameters to users DELETE endpoint
We have been hit by this issue that was raised [in the Community Forum some time ago](https://community.plone.org/t/alternate-way-do-delete-users/3009). We can't delete users in our Volto site because it has a lot of users and content, and the deleting goes through all objects walking up them all.

The solution is to pass `delete_memberareas` and `delete_localroles` as `False` to the `deleteMembers` method of `portal_membership` (knowing, of course, that local roles will not be removed).

So, how can we expose this in the `@users` endpoint's `DELETE` method?

It looks like `DELETE` method calls should not have any request body, so the only alternative is to pass those options as parameters in the url, say

```http
DELETE /@users/erral?delete_localroles=0&delete_memberareas=0
```

Is this acceptable or do you see any other way to handle this?


Discussion / hints:
Another way would be with a custom request header, but the solution with the query string looks fine to me.

Fix patch:
diff --git a/docs/source/endpoints/users.md b/docs/source/endpoints/users.md
index 3bbce14bf9..7a63ea90e7 100644
--- a/docs/source/endpoints/users.md
+++ b/docs/source/endpoints/users.md
@@ -12,7 +12,6 @@ myst:
 Available users in a Plone site can be created, queried, updated, and deleted by interacting with the `/@users` endpoint on portal root.
 This action requires an authenticated user:
 
-
 ## List Users
 
 To retrieve a list of all current users in the portal, call the `/@users` endpoint with a `GET` request:
@@ -104,7 +103,6 @@ The server will respond with a list of users where the `fullname`, `email` or `i
 :language: http
 ```
 
-
 ## Create User
 
 To create a new user, send a `POST` request to the global `/@users` endpoint with a JSON representation of the user you want to create in the body:
@@ -187,7 +185,6 @@ In this case, the server will respond with a {term}`200 OK` status code and the
 :language: http
 ```
 
-
 ## Update User
 
 To update the settings of a user, send a `PATCH` request with the user details you want to amend to the URL of that particular user.
@@ -232,7 +229,6 @@ If you still want Plone to take care of image scaling using the default Plone be
     :request: ../../../src/plone/restapi/tests/http-examples/users_update_portrait_scale.req
 ```
 
-
 ## Delete User
 
 To delete a user, send a `DELETE` request to the `/@users` endpoint and append the user ID of the user you want to delete.
@@ -249,6 +245,47 @@ A successful response will be indicated by a {term}`204 No Content` response:
 :language: http
 ```
 
+When deleting a user in large sites with a lot of users and content the deleting operation may take a lot of time to the extent of setting it in an unresponsive state.
+
+There is a workaround about this which is to request Plone not to delete the Member areas or the local roles that may have been granted in the past.
+
+To mark such a behavior we need to pass specific parameters to the delete endpoint.
+
+In this case we request not to delete the local roles:
+
+```{eval-rst}
+..  http:example:: curl httpie python-requests
+    :request: ../../../src/plone/restapi/tests/http-examples/users_delete_no_localroles.req
+```
+
+A successful response will be indicated by a {term}`204 No Content` response:
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/users_delete_no_localroles.resp
+:language: http
+```
+
+In this case we request not to delete the member areas:
+
+```{eval-rst}
+..  http:example:: curl httpie python-requests
+    :request: ../../../src/plone/restapi/tests/http-examples/users_delete_no_memberareas.req
+```
+
+A successful response will be indicated by a {term}`204 No Content` response:
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/users_delete_no_memberareas.resp
+:language: http
+```
+
+Both parameters can be added in the same request.
+
+```{warning}
+These two specific requests should be made with special care because they may leave traces of the deleted users in the Plone database.
+
+Specifically, although the user is deleted the reference of its permissions stay on the database.
+
+This means that if in the future you add a new user with the same userid, it may get the local roles and member area previously created for the old user.
+```
 
 ## User registration
 
@@ -272,7 +309,6 @@ If the user has been created, the server will respond with a {term}`201 Created`
 :language: http
 ```
 
-
 ## Reset User Password
 
 Plone allows to reset a password for a user by sending a `POST` request to the user resource and appending `/reset-password` to the URL:
@@ -292,7 +328,6 @@ The token that is part of the reset URL in the email can be used to authorize se
     :request: ../../../src/plone/restapi/tests/http-examples/users_reset.req
 ```
 
-
 ### Reset Own Password
 
 Plone also allows a user to reset her own password directly without sending an email.
@@ -316,7 +351,6 @@ To set the password with the old password, you need either the `Set own password
 
 If an API consumer tries to send a password in the payload that is not the same as the currently logged in user, the server will respond with a {term}`400 Bad Request` response.
 
-
 ### Return Values
 
 - {term}`200 OK`
diff --git a/news/1598.feat b/news/1598.feat
new file mode 100644
index 0000000000..9671a17169
--- /dev/null
+++ b/news/1598.feat
@@ -0,0 +1,2 @@
+Allow passing additional parameters to the delete users endpoint to request not to delete local roles and memberareas
+[erral]
diff --git a/src/plone/restapi/services/users/delete.py b/src/plone/restapi/services/users/delete.py
index 905de65d16..e7517f6655 100644
--- a/src/plone/restapi/services/users/delete.py
+++ b/src/plone/restapi/services/users/delete.py
@@ -5,6 +5,9 @@
 from zope.publisher.interfaces import IPublishTraverse
 
 
+FALSE_VALUES = (0, "0", False, "false", "no")
+
+
 @implementer(IPublishTraverse)
 class UsersDelete(Service):
     """Deletes a user."""
@@ -27,7 +30,20 @@ def _get_user_id(self):
     def reply(self):
         portal = getSite()
         portal_membership = getToolByName(portal, "portal_membership")
-        delete_successful = portal_membership.deleteMembers((self._get_user_id,))
+
+        delete_memberareas = (
+            self.request.get("delete_memberareas", True) not in FALSE_VALUES
+        )
+
+        delete_localroles = (
+            self.request.get("delete_localroles", True) not in FALSE_VALUES
+        )
+
+        delete_successful = portal_membership.deleteMembers(
+            (self._get_user_id,),
+            delete_memberareas=delete_memberareas,
+            delete_localroles=delete_localroles,
+        )
         if delete_successful:
             return self.reply_no_content()
         else:


Test patch:
diff --git a/src/plone/restapi/tests/http-examples/users_delete_no_localroles.req b/src/plone/restapi/tests/http-examples/users_delete_no_localroles.req
new file mode 100644
index 0000000000..8bcfb67b3f
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/users_delete_no_localroles.req
@@ -0,0 +1,6 @@
+DELETE /plone/@users/noam HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/x-www-form-urlencoded
+
+delete_localroles=0
\ No newline at end of file
diff --git a/src/plone/restapi/tests/http-examples/users_delete_no_localroles.resp b/src/plone/restapi/tests/http-examples/users_delete_no_localroles.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/users_delete_no_localroles.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/http-examples/users_delete_no_memberareas.req b/src/plone/restapi/tests/http-examples/users_delete_no_memberareas.req
new file mode 100644
index 0000000000..e5c409db1f
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/users_delete_no_memberareas.req
@@ -0,0 +1,6 @@
+DELETE /plone/@users/noam HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/x-www-form-urlencoded
+
+delete_memberareas=0
\ No newline at end of file
diff --git a/src/plone/restapi/tests/http-examples/users_delete_no_memberareas.resp b/src/plone/restapi/tests/http-examples/users_delete_no_memberareas.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/users_delete_no_memberareas.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py
index a99b848569..df5f8aef5b 100644
--- a/src/plone/restapi/tests/test_documentation.py
+++ b/src/plone/restapi/tests/test_documentation.py
@@ -1107,6 +1107,48 @@ def test_documentation_users_delete(self):
         response = self.api_session.delete("/@users/noam")
         save_request_and_response_for_docs("users_delete", response)
 
+    def test_documentation_users_delete_no_localroles(self):
+        properties = {
+            "email": "noam.chomsky@example.com",
+            "username": "noamchomsky",
+            "fullname": "Noam Avram Chomsky",
+            "home_page": "web.mit.edu/chomsky",
+            "description": "Professor of Linguistics",
+            "location": "Cambridge, MA",
+        }
+        api.user.create(
+            email="noam.chomsky@example.com",
+            username="noam",
+            properties=properties,
+        )
+        transaction.commit()
+
+        response = self.api_session.delete(
+            "/@users/noam", data={"delete_localroles": 0}
+        )
+        save_request_and_response_for_docs("users_delete_no_localroles", response)
+
+    def test_documentation_users_delete_no_memberareas(self):
+        properties = {
+            "email": "noam.chomsky@example.com",
+            "username": "noamchomsky",
+            "fullname": "Noam Avram Chomsky",
+            "home_page": "web.mit.edu/chomsky",
+            "description": "Professor of Linguistics",
+            "location": "Cambridge, MA",
+        }
+        api.user.create(
+            email="noam.chomsky@example.com",
+            username="noam",
+            properties=properties,
+        )
+        transaction.commit()
+
+        response = self.api_session.delete(
+            "/@users/noam", data={"delete_memberareas": 0}
+        )
+        save_request_and_response_for_docs("users_delete_no_memberareas", response)
+
     def test_documentation_groups(self):
         gtool = api.portal.get_tool("portal_groups")
         properties = {
diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py
index 6d355f2d4a..b08170eafc 100644
--- a/src/plone/restapi/tests/test_services_users.py
+++ b/src/plone/restapi/tests/test_services_users.py
@@ -40,6 +40,12 @@ def setUp(self):
         self.anon_api_session = RelativeSession(self.portal_url, test=self)
         self.anon_api_session.headers.update({"Accept": "application/json"})
 
+        api.portal.set_registry_record("plone.enable_user_folders", True)
+        api.content.create(
+            container=self.portal, type="Folder", id="Members", title="Members"
+        )
+
+        transaction.commit()
         properties = {
             "email": "noam.chomsky@example.com",
             "username": "noamchomsky",
@@ -66,6 +72,7 @@ def setUp(self):
             password="otherpassword",
         )
         api.group.add_user(groupname="Reviewers", username="otheruser")
+
         transaction.commit()
 
     def tearDown(self):
@@ -159,8 +166,7 @@ def test_add_user_password_is_required(self):
 
         self.assertEqual(400, response.status_code)
         self.assertTrue(
-            ("You have to either send a " "password or sendPasswordReset")
-            in response.text
+            "You have to either send a password or sendPasswordReset" in response.text
         )
 
     def test_add_user_email_is_required_if_email_login_is_enabled(self):
@@ -169,7 +175,8 @@ def test_add_user_email_is_required_if_email_login_is_enabled(self):
         security_settings.use_email_as_login = True
         transaction.commit()
         response = self.api_session.post(
-            "/@users", json={"username": "noam", "password": TEST_USER_PASSWORD}
+            "/@users",
+            json={"username": "noam", "password": TEST_USER_PASSWORD},
         )
 
         self.assertEqual(400, response.status_code)
@@ -182,7 +189,10 @@ def test_add_user_email_with_email_login_enabled(self):
         transaction.commit()
         response = self.api_session.post(
             "/@users",
-            json={"email": "howard.zinn@example.com", "password": TEST_USER_PASSWORD},
+            json={
+                "email": "howard.zinn@example.com",
+                "password": TEST_USER_PASSWORD,
+            },
         )
         transaction.commit()
 
@@ -214,7 +224,10 @@ def test_add_user_with_email_login_enabled(self):
         transaction.commit()
         response = self.api_session.post(
             "/@users",
-            json={"email": "howard.zinn@example.com", "password": TEST_USER_PASSWORD},
+            json={
+                "email": "howard.zinn@example.com",
+                "password": TEST_USER_PASSWORD,
+            },
         )
         transaction.commit()
 
@@ -308,7 +321,10 @@ def test_add_user_with_uuid_as_userid_enabled(self):
         transaction.commit()
         response = self.api_session.post(
             "/@users",
-            json={"email": "howard.zinn@example.com", "password": TEST_USER_PASSWORD},
+            json={
+                "email": "howard.zinn@example.com",
+                "password": TEST_USER_PASSWORD,
+            },
         )
         transaction.commit()
 
@@ -325,7 +341,8 @@ def test_get_user(self):
         self.assertEqual(response.status_code, 200)
         self.assertEqual("noam", response.json().get("id"))
         self.assertEqual(
-            self.portal.absolute_url() + "/@users/noam", response.json().get("@id")
+            self.portal.absolute_url() + "/@users/noam",
+            response.json().get("@id"),
         )
         self.assertEqual("noam.chomsky@example.com", response.json().get("email"))
         self.assertEqual("Noam Avram Chomsky", response.json().get("fullname"))
@@ -377,7 +394,8 @@ def test_get_search_user_with_filter(self):
         self.assertEqual(len(response.json()), 1)
         self.assertEqual("noam", response.json()[0].get("id"))
         self.assertEqual(
-            self.portal.absolute_url() + "/@users/noam", response.json()[0].get("@id")
+            self.portal.absolute_url() + "/@users/noam",
+            response.json()[0].get("@id"),
         )
         self.assertEqual("noam.chomsky@example.com", response.json()[0].get("email"))
         self.assertEqual(
@@ -696,7 +714,10 @@ def test_user_set_own_password_requires_old_and_new_password(self):
 
     def test_user_set_own_password_checks_old_password(self):
         self.api_session.auth = ("noam", "password")
-        payload = {"new_password": "new_password", "old_password": "wrong_password"}
+        payload = {
+            "new_password": "new_password",
+            "old_password": "wrong_password",
+        }
         response = self.api_session.post("/@users/noam/reset-password", json=payload)
 
         self.assertEqual(response.status_code, 403)
@@ -747,7 +768,10 @@ def test_reset_with_uuid_as_userid_and_login_email_using_id(self):
 
         response = self.api_session.post(
             "/@users",
-            json={"email": "howard.zinn@example.com", "password": TEST_USER_PASSWORD},
+            json={
+                "email": "howard.zinn@example.com",
+                "password": TEST_USER_PASSWORD,
+            },
         )
         transaction.commit()
 
@@ -777,7 +801,10 @@ def test_reset_with_uuid_as_userid_and_login_email_using_mail(self):
 
         response = self.api_session.post(
             "/@users",
-            json={"email": "howard.zinn@example.com", "password": TEST_USER_PASSWORD},
+            json={
+                "email": "howard.zinn@example.com",
+                "password": TEST_USER_PASSWORD,
+            },
         )
         transaction.commit()
 
@@ -806,7 +833,10 @@ def test_reset_and_login_email_using_mail(self):
 
         response = self.api_session.post(
             "/@users",
-            json={"email": "howard.zinn@example.com", "password": TEST_USER_PASSWORD},
+            json={
+                "email": "howard.zinn@example.com",
+                "password": TEST_USER_PASSWORD,
+            },
         )
         transaction.commit()
 
@@ -834,6 +864,52 @@ def test_delete_user(self):
         self.assertEqual(response.status_code, 204)
         self.assertEqual(None, api.user.get(userid="noam"))
 
+    def test_delete_user_but_not_memberareas(self):
+        mtool = getToolByName(self.portal, "portal_membership")
+        mtool.createMemberArea("noam")
+
+        response = self.api_session.delete(
+            "/@users/noam", data={"delete_memberareas": 0}
+        )
+        transaction.commit()
+
+        self.assertEqual(response.status_code, 204)
+        self.assertEqual(None, api.user.get(userid="noam"))
+
+        memberfolder = self.portal.get("Members", None)
+        self.assertIsNotNone(memberfolder)
+        if memberfolder is not None:
+            self.assertIn("noam", memberfolder)
+
+    def test_delete_user_but_not_localroles(self):
+        self.folder = api.content.create(
+            container=self.portal,
+            type="Folder",
+            id="folder",
+            title="My Folder",
+        )
+        api.user.grant_roles(username="noam", roles=["Reviewer"], obj=self.folder)
+
+        self.assertIn("Reviewer", api.user.get_roles(username="noam", obj=self.folder))
+
+        response = self.api_session.delete(
+            "/@users/noam", data={"delete_localroles": 0}
+        )
+        transaction.commit()
+
+        self.assertEqual(response.status_code, 204)
+        self.assertEqual(None, api.user.get(userid="noam"))
+
+        user_local_roles = self.folder.get_local_roles_for_userid(userid="noam")
+        self.assertIn("Reviewer", user_local_roles)
+
+    def test_delete_deletes_localroles(self):
+        response = self.api_session.delete("/@users/noam?delete_localroles=0")
+        transaction.commit()
+
+        self.assertEqual(response.status_code, 204)
+        self.assertEqual(None, api.user.get(userid="noam"))
+
     def test_delete_non_existing_user(self):
         response = self.api_session.delete("/@users/non-existing-user")
         transaction.commit()
@@ -857,7 +933,10 @@ def test_anonymous_requires_enable_self_reg(self):
 
         response = self.anon_api_session.post(
             "/@users",
-            json={"username": "new_user", "email": "avram.chomsky@example.com"},
+            json={
+                "username": "new_user",
+                "email": "avram.chomsky@example.com",
+            },
         )
         transaction.commit()
 
@@ -870,7 +949,10 @@ def test_anonymous_without_enable_user_pwd_choice_sends_mail(self):
 
         response = self.anon_api_session.post(
             "/@users",
-            json={"username": "new_user", "email": "avram.chomsky@example.com"},
+            json={
+                "username": "new_user",
+                "email": "avram.chomsky@example.com",
+            },
         )
         transaction.commit()
 
@@ -1038,7 +1120,10 @@ def test_get_user_portrait_if_email_login_enabled(self):
 
         response = self.api_session.post(
             "/@users",
-            json={"email": "howard.zinn@example.com", "password": TEST_USER_PASSWORD},
+            json={
+                "email": "howard.zinn@example.com",
+                "password": TEST_USER_PASSWORD,
+            },
         )
         transaction.commit()