diff --git a/docs/source/Resources/Run/Run_Types.rst b/docs/source/Resources/Run/Run_Types.rst index 0620063..1c54128 100644 --- a/docs/source/Resources/Run/Run_Types.rst +++ b/docs/source/Resources/Run/Run_Types.rst @@ -261,13 +261,52 @@ UserCreateInfo | **name**: str | **email**: str - | **password****: str + | **password**: NotRequired[str] + | Omit when ``generate_password`` is True. | **timezone**: str | **company**: Optional[str] | **phone**: Optional[str] | **language**: Optional[str] | **tags**: Optional[list[:ref:`TagsObj`]] | **active**: Optional[bool] + | **generate_password**: NotRequired[bool] + | Server generates the password and turns on ``force_password_change``. Rejected if ``password`` is also sent. + | **send_email**: NotRequired[Union[bool, str]] + | True sends the ``welcome`` email; a string picks an ``email_templates`` key. Unknown keys fall back to ``welcome``, then to a built-in default. + +.. _UserEditInfo: + +UserEditInfo +------------ + + All attributes are optional. + + **Attributes:** + + | **name**: str + | **password**: str + | **timezone**: str + | **company**: Optional[str] + | **phone**: Optional[str] + | **language**: str + | **newsletter**: Optional[bool] + | **active**: bool + | **options**: Optional[dict] + | **tags**: list[:ref:`TagsObj`] + | **custom_preferences**: Optional[dict] + | **force_password_change**: bool + | **logout_sessions**: bool + | Deletes the user's active sessions. + +.. _UserInviteInfo: + +UserInviteInfo +-------------- + + **Attributes:** + + | **invite_template**: NotRequired[str] + | An ``email_templates`` key to send instead of ``welcome``. .. _UserInfo: @@ -293,6 +332,7 @@ UserInfo | **updated_at**: datetime | **options**: object | **tags**: list[:ref:`TagsObj`] + | **force_password_change**: bool .. _LoginResponseRunUser: diff --git a/docs/source/Resources/Run/index.rst b/docs/source/Resources/Run/index.rst index ce60e23..3135340 100644 --- a/docs/source/Resources/Run/index.rst +++ b/docs/source/Resources/Run/index.rst @@ -127,6 +127,10 @@ userCreate Creates a new user in the Run environment. +Set ``generate_password`` to True to let the server generate the password (omit ``password``) and require a new one at first sign-in. +Set ``send_email`` to True to send the Run's ``welcome`` email, or to an ``email_templates`` key to pick another template. +With ``generate_password`` or ``send_email`` and no ``active``, the user is created active; ``active: False`` together with ``send_email`` is rejected. + See: `TagoRun `_ **Parameters:** @@ -152,6 +156,15 @@ See: `TagoRun `_ }) print(result) # {'user': 'user-id-123'} + # Let the server generate the password and email a set-password link + result = resources.run.userCreate({ + "name": "John Doe", + "email": "john@example.com", + "timezone": "America/New_York", + "generate_password": True, + "send_email": True + }) + ======== userEdit @@ -159,6 +172,8 @@ userEdit Updates information for an existing Run user. +Set ``force_password_change`` to True to require a new password at the next sign-in, and ``logout_sessions`` to True to end the user's active sessions. + See: `TagoRun `_ **Parameters:** @@ -166,7 +181,7 @@ See: `TagoRun `_ | **userID**: :ref:`GenericID` | User identification - | **data**: dict + | **data**: :ref:`UserEditInfo` | User data to update **Returns:** @@ -182,6 +197,43 @@ See: `TagoRun `_ result = resources.run.userEdit("user-id-123", {"name": "Updated Name"}) print(result) # TagoIO Run User Successfully Updated + # Require a new password on next sign-in and end the user's current sessions + result = resources.run.userEdit("user-id-123", {"force_password_change": True, "logout_sessions": True}) + + +================ +userResendInvite +================ + +Resends the set-password link to a Run user whose invite is still pending. +The invite is pending while the user has never signed in and ``force_password_change`` is True. +Can be sent at most once per hour per user. Rejected for an inactive user. + +See: `TagoRun `_ + + **Parameters:** + + | **userID**: :ref:`GenericID` + | User identification + + | *Optional* **data**: :ref:`UserInviteInfo` + | Invite options (e.g., invite_template) + + **Returns:** + + | str + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy **Run User** / **Edit** in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.run.userResendInvite("user-id-123") + print(result) # Invite sent + + result = resources.run.userResendInvite("user-id-123", {"invite_template": "custom_invite"}) + ========== userDelete diff --git a/pyproject.toml b/pyproject.toml index 9c9f6d3..ab5b47f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tagoio-sdk" -version = "5.1.6" +version = "5.1.7" description = "Official Python SDK for TagoIO" authors = [{name = "TagoIO Inc.", email = "contact@tago.io"}] license = {text = "Apache-2.0"} diff --git a/src/tagoio_sdk/modules/Resources/Run.py b/src/tagoio_sdk/modules/Resources/Run.py index e7c4bf2..48288be 100644 --- a/src/tagoio_sdk/modules/Resources/Run.py +++ b/src/tagoio_sdk/modules/Resources/Run.py @@ -1,5 +1,6 @@ from typing import Dict from typing import Optional +from typing import Union from tagoio_sdk.common.Common_Type import GenericID from tagoio_sdk.common.Common_Type import Query @@ -16,7 +17,9 @@ from tagoio_sdk.modules.Resources.Run_Type import RunSAMLEditInfo from tagoio_sdk.modules.Resources.Run_Type import RunSAMLInfo from tagoio_sdk.modules.Resources.Run_Type import UserCreateInfo +from tagoio_sdk.modules.Resources.Run_Type import UserEditInfo from tagoio_sdk.modules.Resources.Run_Type import UserInfo +from tagoio_sdk.modules.Resources.Run_Type import UserInviteInfo from tagoio_sdk.modules.Utils.dateParser import dateParser from tagoio_sdk.modules.Utils.dateParser import dateParserList @@ -152,6 +155,8 @@ def userCreate(self, data: UserCreateInfo) -> Dict[str, str]: """ @description: Creates a new user in the Run environment. + With `generate_password` or `send_email` and no `active`, the user is created active; + `active: False` together with `send_email` is rejected. @see: https://help.tago.io/portal/en/kb/articles/191-tagorun TagoRun @@ -167,6 +172,15 @@ def userCreate(self, data: UserCreateInfo) -> Dict[str, str]: "timezone": "America/New_York" }) print(result) # {'user': 'user-id-123'} + + # Let the server generate the password (omit "password") and email a set-password link + result = resources.run.userCreate({ + "name": "John Doe", + "email": "john@example.com", + "timezone": "America/New_York", + "generate_password": True, + "send_email": True + }) ``` """ result = self.doRequest( @@ -179,7 +193,7 @@ def userCreate(self, data: UserCreateInfo) -> Dict[str, str]: return result - def userEdit(self, userID: GenericID, data: Dict) -> str: + def userEdit(self, userID: GenericID, data: Union[UserEditInfo, Dict]) -> str: """ @description: Updates information for an existing Run user. @@ -193,6 +207,9 @@ def userEdit(self, userID: GenericID, data: Dict) -> str: resources = Resources() result = resources.run.userEdit("user-id-123", {"name": "Updated Name"}) print(result) # TagoIO Run User Successfully Updated + + # Require a new password on next sign-in and end the user's current sessions + result = resources.run.userEdit("user-id-123", {"force_password_change": True, "logout_sessions": True}) ``` """ result = self.doRequest( @@ -205,6 +222,39 @@ def userEdit(self, userID: GenericID, data: Dict) -> str: return result + def userResendInvite( + self, userID: GenericID, data: Optional[UserInviteInfo] = None + ) -> str: + """ + @description: + Resends the set-password link to a Run user whose invite is still pending. + The invite is pending while the user has never signed in and `force_password_change` is True. + Can be sent at most once per hour per user. Rejected for an inactive user. + Pass `{"invite_template": "template-key"}` to pick an `email_templates` key instead of `welcome`. + + @see: + https://help.tago.io/portal/en/kb/articles/191-tagorun TagoRun + + @example: + If receive an error "Authorization Denied", check policy **Run User** / **Edit** in Access Management. + ```python + resources = Resources() + result = resources.run.userResendInvite("user-id-123") + print(result) # Invite sent + + result = resources.run.userResendInvite("user-id-123", {"invite_template": "custom_invite"}) + ``` + """ + result = self.doRequest( + { + "path": f"/run/users/{userID}/invite", + "method": "POST", + "body": data or {}, + } + ) + + return result + def userDelete(self, userID: GenericID) -> str: """ @description: diff --git a/src/tagoio_sdk/modules/Resources/Run_Type.py b/src/tagoio_sdk/modules/Resources/Run_Type.py index 6e22b66..1cde88f 100644 --- a/src/tagoio_sdk/modules/Resources/Run_Type.py +++ b/src/tagoio_sdk/modules/Resources/Run_Type.py @@ -1,6 +1,7 @@ from datetime import datetime from typing import Dict from typing import Literal +from typing import NotRequired from typing import Optional from typing import TypedDict from typing import Union @@ -209,13 +210,46 @@ class RunInfo(TypedDict): class UserCreateInfo(TypedDict): name: str email: str - password: str + password: NotRequired[str] + """ + Omit when `generate_password` is True. + """ timezone: str company: Optional[str] phone: Optional[str] language: Optional[str] tags: Optional[list[TagsObj]] active: Optional[bool] + generate_password: NotRequired[bool] + """ + Server generates the password and turns on `force_password_change`. Rejected if `password` is also sent. + """ + send_email: NotRequired[Union[bool, str]] + """ + True sends the welcome email using the Run's `welcome` template. + A string picks an `email_templates` key; unknown keys fall back to `welcome`, then to a built-in default. + With `generate_password` or `send_email` and no `active`, the user is created active. + `active: False` together with `send_email` is rejected. + """ + + +class UserEditInfo(TypedDict, total=False): + name: str + password: str + timezone: str + company: Optional[str] + phone: Optional[str] + language: str + newsletter: Optional[bool] + active: bool + options: Optional[Dict] + tags: list[TagsObj] + custom_preferences: Optional[Dict] + force_password_change: bool + logout_sessions: bool + """ + Deletes the user's active sessions. + """ class UserInfo(TypedDict): @@ -236,6 +270,14 @@ class UserInfo(TypedDict): updated_at: datetime options: object tags: list[TagsObj] + force_password_change: bool + + +class UserInviteInfo(TypedDict): + invite_template: NotRequired[str] + """ + An `email_templates` key to send instead of `welcome`. + """ class LoginResponse(TypedDict): diff --git a/tests/Resources/test_run.py b/tests/Resources/test_run.py index 67216d8..a37f445 100644 --- a/tests/Resources/test_run.py +++ b/tests/Resources/test_run.py @@ -97,6 +97,10 @@ def mockUserEdit() -> dict: return {"status": True, "result": "TagoIO Run User Successfully Updated"} +def mockUserResendInvite() -> dict: + return {"status": True, "result": "Invite sent"} + + def mockUserDelete() -> dict: return {"status": True, "result": "Successfully Removed"} @@ -262,6 +266,29 @@ def testRunMethodUserCreate(requests_mock: Mocker) -> None: assert result["user"] == "user_id_new" +def testRunMethodUserCreateGeneratePassword(requests_mock: Mocker) -> None: + """Test userCreate method of Run class with a server-generated password.""" + mock_response = mockUserCreate() + requests_mock.post("https://api.tago.io/run/users", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.run.userCreate( + { + "name": "New User", + "email": "newuser@example.com", + "timezone": "America/New_York", + "generate_password": True, + "send_email": "custom_welcome", + } + ) + + assert result["user"] == "user_id_new" + body = requests_mock.last_request.json() + assert body["generate_password"] is True + assert body["send_email"] == "custom_welcome" + assert "password" not in body + + def testRunMethodUserEdit(requests_mock: Mocker) -> None: """Test userEdit method of Run class.""" mock_response = mockUserEdit() @@ -273,6 +300,32 @@ def testRunMethodUserEdit(requests_mock: Mocker) -> None: assert result == "TagoIO Run User Successfully Updated" +def testRunMethodUserResendInvite(requests_mock: Mocker) -> None: + """Test userResendInvite method of Run class without a template.""" + mock_response = mockUserResendInvite() + requests_mock.post("https://api.tago.io/run/users/user_id_1/invite", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.run.userResendInvite("user_id_1") + + assert result == "Invite sent" + assert requests_mock.last_request.method == "POST" + assert requests_mock.last_request.path == "/run/users/user_id_1/invite" + assert requests_mock.last_request.json() == {} + + +def testRunMethodUserResendInviteWithTemplate(requests_mock: Mocker) -> None: + """Test userResendInvite method of Run class with an invite template.""" + mock_response = mockUserResendInvite() + requests_mock.post("https://api.tago.io/run/users/user_id_1/invite", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.run.userResendInvite("user_id_1", {"invite_template": "custom_invite"}) + + assert result == "Invite sent" + assert requests_mock.last_request.json() == {"invite_template": "custom_invite"} + + def testRunMethodUserDelete(requests_mock: Mocker) -> None: """Test userDelete method of Run class.""" mock_response = mockUserDelete() diff --git a/uv.lock b/uv.lock index a98b54a..495e923 100644 --- a/uv.lock +++ b/uv.lock @@ -421,7 +421,7 @@ wheels = [ [[package]] name = "tagoio-sdk" -version = "5.1.6" +version = "5.1.7" source = { editable = "." } dependencies = [ { name = "python-dateutil" },