Repository: posit-dev/posit-sdk-py

Issue:
Add User.lock() and .unlock()
In the API, `locked` is not set by PATCHing the User, it is a separate POST endpoint. Seems reasonable to follow that and have lock/unlock methods on the user to do that.

Also may want a bulk edit method on Users, which accepts guids and (for now at least) updates each one at a time.

Discussion:
github-actions[bot]: # ☂️ Python Coverage
> current status: ✅
## Overall Coverage
| Lines | Covered | Coverage | Threshold | Status |
| :---: | :-----: | :------: | :-------: | :----: |
|  537  |   449   |    84%   |    80%    |   🟢   |
## New Files
No new covered files...
## Modified Files
| File                       | Coverage | Status |
| :------------------------- | :------: | :----: |
| src/posit/connect/users.py |    92%   |   🟢   |
| **TOTAL**                  |  **92%** |   🟢   |


> **updated for commit: `ff2b3dc` by [action](https://github.com/marketplace/actions/python-coverage)🐍**

Patch:
diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py
index aee65f8..7f6ad60 100644
--- a/src/posit/connect/client.py
+++ b/src/posit/connect/client.py
@@ -3,7 +3,7 @@ from __future__ import annotations
 from requests import Response, Session
 from typing import Optional
 
-from . import hooks, urls
+from . import hooks, me, urls
 
 from .auth import Auth
 from .config import Config
@@ -48,9 +48,7 @@ class Client:
 
     @property
     def me(self) -> User:
-        url = urls.append_path(self.config.url, "v1/user")
-        response = self.session.get(url)
-        return User(self.config, self.session, **response.json())
+        return me.get(self.config, self.session)
 
     @property
     def oauth(self) -> OAuthIntegration:
diff --git a/src/posit/connect/me.py b/src/posit/connect/me.py
new file mode 100644
index 0000000..71ad7e5
--- /dev/null
+++ b/src/posit/connect/me.py
@@ -0,0 +1,22 @@
+import requests
+
+from . import urls
+
+from .config import Config
+from .users import User
+
+
+def get(config: Config, session: requests.Session) -> User:
+    """
+    Gets the current user.
+
+    Args:
+        config (Config): The configuration object containing the URL.
+        session (requests.Session): The session object used for making HTTP requests.
+
+    Returns:
+        User: The current user.
+    """
+    url = urls.append_path(config.url, "v1/user")
+    response = session.get(url)
+    return User(config, session, **response.json())
diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py
index aff680a..ba48be1 100644
--- a/src/posit/connect/users.py
+++ b/src/posit/connect/users.py
@@ -5,7 +5,7 @@ from typing import List, Optional
 import requests
 
 
-from . import urls
+from . import me, urls
 
 from .config import Config
 from .paginator import Paginator
@@ -57,6 +57,40 @@ class User(Resource):
     def locked(self) -> bool:
         return self.get("locked")  # type: ignore
 
+    def lock(self, *, force: bool = False):
+        """
+        Locks the user account.
+
+        .. warning:: You cannot unlock your own account. Once an account is locked, only an admin can unlock it.
+
+        Args:
+            force (bool, optional): If set to True, overrides lock protection allowing a user to lock their own account. Defaults to False.
+
+        Returns:
+            None
+        """
+        _me = me.get(self.config, self.session)
+        if _me.guid == self.guid and not force:
+            raise RuntimeError(
+                "You cannot lock your own account. Set force=True to override this behavior."
+            )
+        url = urls.append_path(self.config.url, f"v1/users/{self.guid}/lock")
+        body = {"locked": True}
+        self.session.post(url, json=body)
+        super().update(locked=True)
+
+    def unlock(self):
+        """
+        Unlocks the user account.
+
+        Returns:
+            None
+        """
+        url = urls.append_path(self.config.url, f"v1/users/{self.guid}/lock")
+        body = {"locked": False}
+        self.session.post(url, json=body)
+        super().update(locked=False)
+
     def _update(self, body):
         if len(body) == 0:
             return
@@ -126,13 +160,12 @@ class Users(Resources[User]):
         return next(users, None)
 
     def get(self, id: str) -> User:
-        url = urls.append_path(self.url, id)
+        url = urls.append_path(self.config.url, f"v1/users/{id}")
         response = self.session.get(url)
-        raw_user = response.json()
         return User(
             config=self.config,
             session=self.session,
-            **raw_user,
+            **response.json(),
         )
 
     def create(self) -> User:


Test patch:
diff --git a/tests/posit/connect/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6.json b/tests/posit/connect/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6.json
new file mode 100644
index 0000000..d01b5f6
--- /dev/null
+++ b/tests/posit/connect/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6.json
@@ -0,0 +1,13 @@
+{
+    "email": "random_email@example.com",
+    "username": "random_username",
+    "first_name": "Random",
+    "last_name": "User",
+    "user_role": "admin",
+    "created_time": "2022-01-01T00:00:00Z",
+    "updated_time": "2022-03-15T12:34:56Z",
+    "active_time": "2022-02-28T18:30:00Z",
+    "confirmed": false,
+    "locked": true,
+    "guid": "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6"
+}
diff --git a/tests/posit/connect/test_users.py b/tests/posit/connect/test_users.py
index 237c2d7..c61e8c3 100644
--- a/tests/posit/connect/test_users.py
+++ b/tests/posit/connect/test_users.py
@@ -92,6 +92,87 @@ class TestUser:
         user = User(session, url, locked=False)
         assert user.locked is False
 
+    @responses.activate
+    def test_lock(self):
+        responses.get(
+            "https://connect.example/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6",
+            json=load_mock("v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6.json"),
+        )
+        c = Client(api_key="12345", url="https://connect.example/")
+        user = c.users.get("a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6")
+        assert user.guid == "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6"
+
+        responses.get(
+            "https://connect.example/__api__/v1/user",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        responses.post(
+            "https://connect.example/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6/lock",
+            match=[responses.matchers.json_params_matcher({"locked": True})],
+        )
+        user.lock()
+        assert user.locked
+
+    @responses.activate
+    def test_lock_self_true(self):
+        responses.get(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        c = Client(api_key="12345", url="https://connect.example/")
+        user = c.users.get("20a79ce3-6e87-4522-9faf-be24228800a4")
+        assert user.guid == "20a79ce3-6e87-4522-9faf-be24228800a4"
+
+        responses.get(
+            "https://connect.example/__api__/v1/user",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        responses.post(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock",
+            match=[responses.matchers.json_params_matcher({"locked": True})],
+        )
+        user.lock(force=True)
+        assert user.locked
+
+    @responses.activate
+    def test_lock_self_false(self):
+        responses.get(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        c = Client(api_key="12345", url="https://connect.example/")
+        user = c.users.get("20a79ce3-6e87-4522-9faf-be24228800a4")
+        assert user.guid == "20a79ce3-6e87-4522-9faf-be24228800a4"
+
+        responses.get(
+            "https://connect.example/__api__/v1/user",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        responses.post(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock",
+            match=[responses.matchers.json_params_matcher({"locked": True})],
+        )
+        with pytest.raises(RuntimeError):
+            user.lock(force=False)
+        assert not user.locked
+
+    @responses.activate
+    def test_unlock(self):
+        responses.get(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        c = Client(api_key="12345", url="https://connect.example/")
+        user = c.users.get("20a79ce3-6e87-4522-9faf-be24228800a4")
+        assert user.guid == "20a79ce3-6e87-4522-9faf-be24228800a4"
+
+        responses.post(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock",
+            match=[responses.matchers.json_params_matcher({"locked": False})],
+        )
+        user.unlock()
+        assert not user.locked
+
 
 class TestUsers:
     @responses.activate