diff --git a/README.md b/README.md index 4625b2c..ec67fae 100644 --- a/README.md +++ b/README.md @@ -4134,6 +4134,113 @@ client.create_model_monitoring_request_results( ) ``` +## Workspace User + +### Get workspace users + +Returns a list of internal workspace users. (Up to 20 at a time by default) +Each user includes its granted module permissions in `functionResourcePermissions`. + +```python +import fastlabel +client = fastlabel.Client() + +users = client.get_workspace_users( + keyword="", # Search keyword for name or email (Optional) + offset=0, # The starting position number to fetch (Optional) + limit=20, # The max number to fetch (Optional, default 20) +) +# [ +# { +# "id": "...", +# "userId": "...", +# "userSlug": "...", +# "userName": "John Doe", +# "userEmail": "john@example.com", +# "role": "member", +# "isExternal": False, +# "createdAt": "...", +# "updatedAt": "...", +# "functionResourcePermissions": { +# "annotation": True, +# "modelDev": False, +# "dataset": False +# } +# } +# ] +``` + +### Create workspace user + +Creates an internal workspace user. The `slug` is generated automatically on the server side. +Module permissions are managed separately (see below). + +```python +user = client.create_workspace_user( + name="John Doe", + email="john@example.com", + language="en", # 'en' or 'ja' + role="member", # 'member' or 'owner' +) +``` + +### Update workspace user + +Updates an internal workspace user. Only the `role` can be changed. + +```python +user = client.update_workspace_user( + id="YOUR_WORKSPACE_USER_ID", + role="owner", # 'member' or 'owner' (Optional) +) +``` + +### Delete workspace user + +Deletes an internal workspace user. + +```python +client.delete_workspace_user(id="YOUR_WORKSPACE_USER_ID") +``` + +### Grant module permissions + +Grants module permissions to an internal workspace user. +`modules` accepts a single module or a list (each is sent as a separate request). + +```python +# Single module +client.create_workspace_user_module_permissions( + workspace_user_id="YOUR_WORKSPACE_USER_ID", + modules="annotation", # 'annotation', 'modelDev' or 'dataset' +) + +# Multiple modules +client.create_workspace_user_module_permissions( + workspace_user_id="YOUR_WORKSPACE_USER_ID", + modules=["annotation", "dataset"], +) +``` + +### Revoke module permissions + +Revokes module permissions from an internal workspace user. +`modules` accepts a single module or a list (each is sent as a separate request). + +```python +# Single module +client.delete_workspace_user_module_permissions( + workspace_user_id="YOUR_WORKSPACE_USER_ID", + modules="annotation", # 'annotation', 'modelDev' or 'dataset' +) + +# Multiple modules +client.delete_workspace_user_module_permissions( + workspace_user_id="YOUR_WORKSPACE_USER_ID", + modules=["annotation", "dataset"], +) +``` + ## API Docs Check [this](https://api.fastlabel.ai/docs/) for further information. diff --git a/fastlabel/__init__.py b/fastlabel/__init__.py index 0155359..e2c8f0f 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -5471,6 +5471,140 @@ def get_project_comments( params["limit"] = limit return self.api.get_request(endpoint, params=params) + def get_workspace_users( + self, + keyword: str = None, + offset: int = None, + limit: int = 20, + ) -> list: + """ + Returns a list of internal workspace users. + keyword is a search keyword for name or email (Optional). + offset is the starting position number to fetch (Optional). + limit is the max number to fetch (Optional, default 20). + """ + endpoint = "workspaces-users" + params = {} + if keyword: + params["keyword"] = keyword + if offset is not None: + params["offset"] = offset + if limit is not None: + params["limit"] = limit + return self.api.get_request(endpoint, params=params) + + def create_workspace_user( + self, + name: str, + email: str, + language: str, + role: str, + ) -> dict: + """ + Creates an internal workspace user and returns the created user. + name is the user's name (Required). + email is the user's email address (Required). + language is the user's language, 'en' or 'ja' (Required). + role is the workspace role, 'member' or 'owner' (Required). + Module permissions are managed separately; use + create_workspace_user_module_permission to grant them. + """ + endpoint = "workspaces-users/internal-users" + payload = { + "name": name, + "email": email, + "language": language, + "role": role, + } + return self.api.post_request(endpoint, payload=payload) + + def update_workspace_user( + self, + id: str, + role: str = None, + ) -> dict: + """ + Updates an internal workspace user and returns the updated user. + Only the role can be changed. + id is the id of the workspace user (Required). + role is the workspace role, 'member' or 'owner' (Optional). + """ + endpoint = f"workspaces-users/internal-users/{id}" + payload = {} + if role: + payload["role"] = role + return self.api.put_request(endpoint, payload=payload) + + def delete_workspace_user(self, id: str) -> None: + """ + Deletes an internal workspace user. + id is the id of the workspace user (Required). + """ + endpoint = f"workspaces-users/internal-users/{id}" + self.api.delete_request(endpoint) + + def create_workspace_user_module_permissions( + self, + workspace_user_id: str, + modules: Union[str, List[str]], + ) -> List[str]: + """ + Grants module permissions to an internal workspace user. + Each module is granted with a separate request; if one fails (e.g. the + module user limit is reached), the permissions granted before it remain. + workspace_user_id is the id of the workspace user (Required). + modules is a single module or a list of modules, each one of + 'annotation', 'modelDev', 'dataset' (Required). + """ + if isinstance(modules, str): + modules = [modules] + module_paths = { + "annotation": "annotation", + "dataset": "dataset", + "modelDev": "model-dev", + } + results = [] + for module in modules: + if module not in module_paths: + raise FastLabelInvalidException( + "module must be one of 'annotation', 'modelDev', 'dataset'.", 422 + ) + endpoint = ( + f"function-resource-permissions/{module_paths[module]}/internal-users" + ) + results.append( + self.api.post_request( + endpoint, payload={"workspaceUserId": workspace_user_id} + ) + ) + return results + + def delete_workspace_user_module_permissions( + self, + workspace_user_id: str, + modules: Union[str, List[str]], + ) -> None: + """ + Revokes module permissions from an internal workspace user. + Each module is revoked with a separate request; if one fails, the + permissions revoked before it remain revoked. + workspace_user_id is the id of the workspace user (Required). + modules is a single module or a list of modules, each one of + 'annotation', 'modelDev', 'dataset' (Required). + """ + if isinstance(modules, str): + modules = [modules] + endpoint = "function-resource-permissions" + for module in modules: + if module not in ("annotation", "modelDev", "dataset"): + raise FastLabelInvalidException( + "module must be one of 'annotation', 'modelDev', 'dataset'.", 422 + ) + self.api.delete_request( + endpoint, + payload={"workspaceUserId": workspace_user_id, "resource": module}, + ) + def mask_to_fastlabel_segmentation_points( self, mask_image: Union[str, np.ndarray] ) -> List[List[List[int]]]: diff --git a/fastlabel/api.py b/fastlabel/api.py index 20e5188..6df9c8c 100644 --- a/fastlabel/api.py +++ b/fastlabel/api.py @@ -44,7 +44,7 @@ def get_request(self, endpoint: str, params=None) -> Union[dict, list]: else: raise FastLabelException(error, r.status_code) - def delete_request(self, endpoint: str, params=None) -> dict: + def delete_request(self, endpoint: str, params=None, payload=None) -> dict: """Makes a delete request to an endpoint. If an error occurs, assumes that endpoint returns JSON as: { 'statusCode': XXX, @@ -55,7 +55,9 @@ def delete_request(self, endpoint: str, params=None) -> dict: "Content-Type": "application/json", "Authorization": self.access_token, } - r = requests.delete(self.base_url + endpoint, headers=headers, params=params) + r = requests.delete( + self.base_url + endpoint, headers=headers, params=params, json=payload + ) if r.status_code == 200 or r.status_code == 204: return diff --git a/fastlabel/exceptions.py b/fastlabel/exceptions.py index 7f0a4ba..62415f8 100644 --- a/fastlabel/exceptions.py +++ b/fastlabel/exceptions.py @@ -1,10 +1,14 @@ class FastLabelException(Exception): - def __init__(self, message, errcode): + def __init__(self, message, errcode=None): super(FastLabelException, self).__init__( " {}".format(errcode, message) ) + self.message = message self.code = errcode + def __reduce__(self): + return (self.__class__, (self.message, self.code)) + class FastLabelInvalidException(FastLabelException, ValueError): pass diff --git a/tests/test_workspace_user.py b/tests/test_workspace_user.py new file mode 100644 index 0000000..90176fa --- /dev/null +++ b/tests/test_workspace_user.py @@ -0,0 +1,206 @@ +"""Tests for the workspace user API client methods. + +These verify that get/create/update/delete_workspace_user build the correct +endpoint, query params and payload. The HTTP layer (client.api.*_request) is +stubbed so no real request is made. +""" +import pytest + +import fastlabel + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv("FASTLABEL_ACCESS_TOKEN", "dummy-token") + return fastlabel.Client() + + +def _capture(monkeypatch, client, method_name, return_value=None): + """Replace an api.*_request method with a recorder and return the calls list.""" + calls = [] + + def fake(endpoint, *args, **kwargs): + calls.append({"endpoint": endpoint, "args": args, "kwargs": kwargs}) + return return_value + + monkeypatch.setattr(client.api, method_name, fake) + return calls + + +# --- get_workspace_users --------------------------------------------------- + + +def test_get_workspace_users_default(monkeypatch, client): + calls = _capture(monkeypatch, client, "get_request", return_value=[]) + + client.get_workspace_users() + + assert calls[0]["endpoint"] == "workspaces-users" + # keyword/offset are omitted, limit defaults to 20 + assert calls[0]["kwargs"]["params"] == {"limit": 20} + + +def test_get_workspace_users_with_params(monkeypatch, client): + calls = _capture(monkeypatch, client, "get_request", return_value=[]) + + client.get_workspace_users(keyword="john", offset=10, limit=50) + + assert calls[0]["kwargs"]["params"] == { + "keyword": "john", + "offset": 10, + "limit": 50, + } + + +def test_get_workspace_users_offset_zero_included(monkeypatch, client): + calls = _capture(monkeypatch, client, "get_request", return_value=[]) + + client.get_workspace_users(offset=0) + + # offset=0 should still be sent (is not None), keyword empty is omitted + assert calls[0]["kwargs"]["params"] == {"offset": 0, "limit": 20} + + +# --- create_workspace_user ------------------------------------------------- + + +def test_create_workspace_user_without_modules(monkeypatch, client): + calls = _capture(monkeypatch, client, "post_request", return_value={}) + + client.create_workspace_user( + name="John Doe", + email="john@example.com", + language="en", + role="member", + ) + + assert calls[0]["endpoint"] == "workspaces-users/internal-users" + assert calls[0]["kwargs"]["payload"] == { + "name": "John Doe", + "email": "john@example.com", + "language": "en", + "role": "member", + } + + +# --- update_workspace_user ------------------------------------------------- + + +def test_update_workspace_user_role_only(monkeypatch, client): + calls = _capture(monkeypatch, client, "put_request", return_value={}) + + client.update_workspace_user(id="wsu-1", role="owner") + + assert calls[0]["endpoint"] == "workspaces-users/internal-users/wsu-1" + assert calls[0]["kwargs"]["payload"] == {"role": "owner"} + + +def test_update_workspace_user_role_omitted(monkeypatch, client): + calls = _capture(monkeypatch, client, "put_request", return_value={}) + + client.update_workspace_user(id="wsu-1") + + # role omitted -> empty payload + assert calls[0]["kwargs"]["payload"] == {} + + +# --- delete_workspace_user ------------------------------------------------- + + +def test_delete_workspace_user(monkeypatch, client): + calls = _capture(monkeypatch, client, "delete_request", return_value=None) + + result = client.delete_workspace_user(id="wsu-1") + + assert calls[0]["endpoint"] == "workspaces-users/internal-users/wsu-1" + assert result is None + + +# --- create_workspace_user_module_permissions ------------------------------ + + +@pytest.mark.parametrize( + "module, expected_path", + [ + ("annotation", "function-resource-permissions/annotation/internal-users"), + ("dataset", "function-resource-permissions/dataset/internal-users"), + ("modelDev", "function-resource-permissions/model-dev/internal-users"), + ], +) +def test_create_module_permissions_single(monkeypatch, client, module, expected_path): + calls = _capture(monkeypatch, client, "post_request", return_value=module) + + # a single module string is accepted (not only a list) + result = client.create_workspace_user_module_permissions( + workspace_user_id="wsu-1", modules=module + ) + + assert len(calls) == 1 + assert calls[0]["endpoint"] == expected_path + assert calls[0]["kwargs"]["payload"] == {"workspaceUserId": "wsu-1"} + assert result == [module] + + +def test_create_module_permissions_multiple(monkeypatch, client): + calls = _capture(monkeypatch, client, "post_request", return_value="ok") + + result = client.create_workspace_user_module_permissions( + workspace_user_id="wsu-1", modules=["annotation", "dataset"] + ) + + assert [c["endpoint"] for c in calls] == [ + "function-resource-permissions/annotation/internal-users", + "function-resource-permissions/dataset/internal-users", + ] + assert all(c["kwargs"]["payload"] == {"workspaceUserId": "wsu-1"} for c in calls) + assert result == ["ok", "ok"] + + +def test_create_module_permissions_invalid_module(monkeypatch, client): + _capture(monkeypatch, client, "post_request", return_value=None) + + with pytest.raises(fastlabel.exceptions.FastLabelInvalidException): + client.create_workspace_user_module_permissions( + workspace_user_id="wsu-1", modules="unknown" + ) + + +# --- delete_workspace_user_module_permissions ------------------------------ + + +def test_delete_module_permissions_single(monkeypatch, client): + calls = _capture(monkeypatch, client, "delete_request", return_value=None) + + client.delete_workspace_user_module_permissions( + workspace_user_id="wsu-1", modules="modelDev" + ) + + assert len(calls) == 1 + assert calls[0]["endpoint"] == "function-resource-permissions" + assert calls[0]["kwargs"]["payload"] == { + "workspaceUserId": "wsu-1", + "resource": "modelDev", + } + + +def test_delete_module_permissions_multiple(monkeypatch, client): + calls = _capture(monkeypatch, client, "delete_request", return_value=None) + + client.delete_workspace_user_module_permissions( + workspace_user_id="wsu-1", modules=["annotation", "modelDev"] + ) + + assert [c["kwargs"]["payload"]["resource"] for c in calls] == [ + "annotation", + "modelDev", + ] + assert all(c["endpoint"] == "function-resource-permissions" for c in calls) + + +def test_delete_module_permissions_invalid_module(monkeypatch, client): + _capture(monkeypatch, client, "delete_request", return_value=None) + + with pytest.raises(fastlabel.exceptions.FastLabelInvalidException): + client.delete_workspace_user_module_permissions( + workspace_user_id="wsu-1", modules="unknown" + )