diff --git a/README.md b/README.md index 4625b2c..de01ae6 100644 --- a/README.md +++ b/README.md @@ -3836,6 +3836,7 @@ Example of two training jobs. "tags": [], "contentCount": 23, "userName": "Admin", + "userEmail": "admin@example.com", "createdAt": "2023-10-31T07:10:28.306Z", "completedAt": null, "customModel": { @@ -3862,6 +3863,7 @@ Example of two training jobs. ], "contentCount": 20, "userName": "Admin", + "userEmail": "admin@example.com", "createdAt": "2023-10-31T06:56:28.112Z", "completedAt": "2023-10-31T07:08:26.000Z", "customModel": { @@ -4134,6 +4136,116 @@ 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. The user is identified by `email` and only +the `role` can be changed. Passing `role="none"` removes the user from the +workspace (equivalent to `delete_workspace_user`). + +```python +user = client.update_workspace_user( + email="john@example.com", + role="owner", # 'member', 'owner' or 'none' +) +``` + +### Delete workspace user + +Removes an internal workspace user from the workspace. There is no dedicated +delete endpoint; this updates the user's role to `none`. + +```python +client.delete_workspace_user(email="john@example.com") +``` + +### 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( + email="john@example.com", + modules="annotation", # 'annotation', 'modelDev' or 'dataset' +) + +# Multiple modules +client.create_workspace_user_module_permissions( + email="john@example.com", + 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( + email="john@example.com", + modules="annotation", # 'annotation', 'modelDev' or 'dataset' +) + +# Multiple modules +client.delete_workspace_user_module_permissions( + email="john@example.com", + 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..5f283fe 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -5471,6 +5471,138 @@ 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, + email: str, + role: str, + ) -> dict: + """ + Updates an internal workspace user and returns the updated user. + The user is identified by email. Only the role can be changed. + Passing role='none' removes the user from the workspace + (equivalent to delete_workspace_user). + email is the email address of the workspace user (Required). + role is the workspace role, 'member', 'owner' or 'none' (Required). + """ + endpoint = "workspaces-users/internal-users" + payload = {"email": email, "role": role} + return self.api.put_request(endpoint, payload=payload) + + def delete_workspace_user(self, email: str) -> None: + """ + Removes an internal workspace user from the workspace. + There is no dedicated delete endpoint; this is done by updating the + user's role to 'none'. + email is the email address of the workspace user (Required). + """ + endpoint = "workspaces-users/internal-users" + self.api.put_request(endpoint, payload={"email": email, "role": "none"}) + + def create_workspace_user_module_permissions( + self, + email: 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. + email is the email address 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={"email": email})) + return results + + def delete_workspace_user_module_permissions( + self, + email: 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. + email is the email address 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={"email": email, "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..fadb6f0 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 @@ -110,7 +112,11 @@ def put_request(self, endpoint, payload=None): r = requests.put(self.base_url + endpoint, json=payload, headers=headers) if r.status_code == 200: + if not r.content: + return return r.json() + elif r.status_code == 204: + return else: try: error = r.json()["message"] 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..9d3bc8a --- /dev/null +++ b/tests/test_workspace_user.py @@ -0,0 +1,205 @@ +"""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(monkeypatch, client): + calls = _capture(monkeypatch, client, "put_request", return_value={}) + + client.update_workspace_user(email="john@example.com", role="owner") + + assert calls[0]["endpoint"] == "workspaces-users/internal-users" + assert calls[0]["kwargs"]["payload"] == { + "email": "john@example.com", + "role": "owner", + } + + +# --- delete_workspace_user ------------------------------------------------- + + +def test_delete_workspace_user(monkeypatch, client): + # deletion is performed via PUT with role='none' (no DELETE endpoint) + calls = _capture(monkeypatch, client, "put_request", return_value=None) + + result = client.delete_workspace_user(email="john@example.com") + + assert calls[0]["endpoint"] == "workspaces-users/internal-users" + assert calls[0]["kwargs"]["payload"] == { + "email": "john@example.com", + "role": "none", + } + 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( + email="john@example.com", modules=module + ) + + assert len(calls) == 1 + assert calls[0]["endpoint"] == expected_path + assert calls[0]["kwargs"]["payload"] == {"email": "john@example.com"} + 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( + email="john@example.com", 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"] == {"email": "john@example.com"} 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( + email="john@example.com", 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( + email="john@example.com", modules="modelDev" + ) + + assert len(calls) == 1 + assert calls[0]["endpoint"] == "function-resource-permissions" + assert calls[0]["kwargs"]["payload"] == { + "email": "john@example.com", + "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( + email="john@example.com", 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( + email="john@example.com", modules="unknown" + )