diff --git a/pybotx/__init__.py b/pybotx/__init__.py index fa50a5c4..23074581 100644 --- a/pybotx/__init__.py +++ b/pybotx/__init__.py @@ -64,7 +64,10 @@ FinalRecipientsListEmptyError, StealthModeDisabledError, ) -from pybotx.client.exceptions.users import UserNotFoundError +from pybotx.client.exceptions.users import ( + UserNotFoundError, + UserProfileUpdateUnavailableError, +) from pybotx.client.smartapps_api.exceptions import SyncSmartAppEventHandlerNotFoundError from pybotx.client.smartapps_api.smartapp_manifest import ( SmartappManifest, @@ -301,6 +304,7 @@ "UserFromSearch", "UserKinds", "UserNotFoundError", + "UserProfileUpdateUnavailableError", "UserSender", "Video", "Voice", diff --git a/pybotx/bot/bot.py b/pybotx/bot/bot.py index aef8eb17..c85f3719 100644 --- a/pybotx/bot/bot.py +++ b/pybotx/bot/bot.py @@ -1421,11 +1421,15 @@ async def search_user_by_emails( *, bot_id: UUID, emails: list[str], + trusts_search: bool = False, + partial_response: bool = False, ) -> list[UserFromSearch]: """Search user by emails for search. :param bot_id: Bot which should perform the request. :param emails: User emails. + :param trusts_search: Search users on trusted servers. + :param partial_response: Return local results if trusted server lookup fails. :return: Search result with user information. """ @@ -1435,7 +1439,11 @@ async def search_user_by_emails( self._httpx_client, self._bot_accounts_storage, ) - payload = BotXAPISearchUserByEmailsRequestPayload.from_domain(emails=emails) + payload = BotXAPISearchUserByEmailsRequestPayload.from_domain( + emails=emails, + trusts_search=trusts_search, + partial_response=partial_response, + ) botx_api_users_from_search = await method.execute(payload) @@ -1447,6 +1455,8 @@ async def search_user_by_email_post( *, bot_id: UUID, email: str, + trusts_search: bool = False, + partial_response: bool = False, ) -> UserFromSearch: """Search user by email for search. @@ -1455,6 +1465,8 @@ async def search_user_by_email_post( :param bot_id: Bot which should perform the request. :param email: User email. + :param trusts_search: Search users on trusted servers. + :param partial_response: Return local results if trusted server lookup fails. :return: User information. """ @@ -1464,7 +1476,11 @@ async def search_user_by_email_post( self._httpx_client, self._bot_accounts_storage, ) - payload = BotXAPISearchUserByEmailRequestPayload.from_domain(email=email) + payload = BotXAPISearchUserByEmailRequestPayload.from_domain( + email=email, + trusts_search=trusts_search, + partial_response=partial_response, + ) botx_api_user_from_search = await method.execute(payload) diff --git a/pybotx/client/exceptions/users.py b/pybotx/client/exceptions/users.py index c6fc913f..a92a4310 100644 --- a/pybotx/client/exceptions/users.py +++ b/pybotx/client/exceptions/users.py @@ -11,3 +11,7 @@ class InvalidProfileDataError(BaseClientError): class NoUserKindSelectedError(BaseClientError): """No user kind selected.""" + + +class UserProfileUpdateUnavailableError(BaseClientError): + """User profile update service is unavailable.""" diff --git a/pybotx/client/users_api/search_user_by_email.py b/pybotx/client/users_api/search_user_by_email.py index ad11a589..1aacf10c 100644 --- a/pybotx/client/users_api/search_user_by_email.py +++ b/pybotx/client/users_api/search_user_by_email.py @@ -9,15 +9,27 @@ BotXAPISearchUserResponsePayload, ) from pybotx.logger import logger +from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel class BotXAPISearchUserByEmailRequestPayload(UnverifiedPayloadBaseModel): email: str + trusts_search: Missing[bool] = Undefined + partial_response: Missing[bool] = Undefined @classmethod - def from_domain(cls, email: str) -> "BotXAPISearchUserByEmailRequestPayload": - return cls(email=email) + def from_domain( + cls, + email: str, + trusts_search: bool = False, + partial_response: bool = False, + ) -> "BotXAPISearchUserByEmailRequestPayload": + return cls( + email=email, + trusts_search=trusts_search or Undefined, + partial_response=partial_response or Undefined, + ) class SearchUserByEmailMethod(AuthorizedBotXMethod): @@ -67,7 +79,14 @@ async def execute( path = "/api/v3/botx/users/by_email" email = payload.email - request_json = {"emails": [email]} + request_json = { + "emails": [email], + "trusts_search": payload.trusts_search, + "partial_response": payload.partial_response, + } + request_json = { + key: value for key, value in request_json.items() if value is not Undefined + } response = await self._botx_method_call( "POST", diff --git a/pybotx/client/users_api/search_user_by_emails.py b/pybotx/client/users_api/search_user_by_emails.py index 8981c355..8f024c4f 100644 --- a/pybotx/client/users_api/search_user_by_emails.py +++ b/pybotx/client/users_api/search_user_by_emails.py @@ -3,18 +3,27 @@ from pybotx.client.users_api.user_from_search import ( BotXAPISearchUserByEmailsResponsePayload, ) +from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel class BotXAPISearchUserByEmailsRequestPayload(UnverifiedPayloadBaseModel): emails: list[str] + trusts_search: Missing[bool] = Undefined + partial_response: Missing[bool] = Undefined @classmethod def from_domain( cls, emails: list[str], + trusts_search: bool = False, + partial_response: bool = False, ) -> "BotXAPISearchUserByEmailsRequestPayload": - return cls(emails=emails) + return cls( + emails=emails, + trusts_search=trusts_search or Undefined, + partial_response=partial_response or Undefined, + ) class SearchUserByEmailsMethod(AuthorizedBotXMethod): diff --git a/pybotx/client/users_api/update_user_profile.py b/pybotx/client/users_api/update_user_profile.py index 5f638f57..a257d42f 100644 --- a/pybotx/client/users_api/update_user_profile.py +++ b/pybotx/client/users_api/update_user_profile.py @@ -3,7 +3,11 @@ from pybotx.client.authorized_botx_method import AuthorizedBotXMethod from pybotx.client.botx_method import response_exception_thrower -from pybotx.client.exceptions.users import InvalidProfileDataError, UserNotFoundError +from pybotx.client.exceptions.users import ( + InvalidProfileDataError, + UserNotFoundError, + UserProfileUpdateUnavailableError, +) from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel from pybotx.models.attachments import ( @@ -67,6 +71,7 @@ class UpdateUsersProfileMethod(AuthorizedBotXMethod): **AuthorizedBotXMethod.status_handlers, 400: response_exception_thrower(InvalidProfileDataError), 404: response_exception_thrower(UserNotFoundError), + 503: response_exception_thrower(UserProfileUpdateUnavailableError), } async def execute( diff --git a/pybotx/client/users_api/user_from_csv.py b/pybotx/client/users_api/user_from_csv.py index 275b9645..88b9bb38 100644 --- a/pybotx/client/users_api/user_from_csv.py +++ b/pybotx/client/users_api/user_from_csv.py @@ -23,17 +23,17 @@ class BotXAPIUserFromCSVResult(VerifiedPayloadBaseModel): company: str | None = Field(alias="Company") department: str | None = Field(alias="Department") position: str | None = Field(alias="Position") - avatar: str | None = Field(alias="Avatar") - avatar_preview: str | None = Field(alias="Avatar preview") - office: str | None = Field(alias="Office") + avatar: str | None = Field(default=None, alias="Avatar") + avatar_preview: str | None = Field(default=None, alias="Avatar preview") + office: str | None = Field(default=None, alias="Office") manager: str | None = Field(alias="Manager") manager_huid: UUID | None = Field(alias="Manager HUID") - description: str | None = Field(alias="Description") - phone: str | None = Field(alias="Phone") - other_phone: str | None = Field(alias="Other phone") - ip_phone: str | None = Field(alias="IP phone") - other_ip_phone: str | None = Field(alias="Other IP phone") - personnel_number: str | None = Field(alias="Personnel number") + description: str | None = Field(default=None, alias="Description") + phone: str | None = Field(default=None, alias="Phone") + other_phone: str | None = Field(default=None, alias="Other phone") + ip_phone: str | None = Field(default=None, alias="IP phone") + other_ip_phone: str | None = Field(default=None, alias="Other IP phone") + personnel_number: str | None = Field(default=None, alias="Personnel number") @field_validator( "email", diff --git a/pybotx/client/users_api/user_from_search.py b/pybotx/client/users_api/user_from_search.py index 92d257c5..17d6ba34 100644 --- a/pybotx/client/users_api/user_from_search.py +++ b/pybotx/client/users_api/user_from_search.py @@ -5,7 +5,7 @@ from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.enums import APIUserKinds, convert_user_kind_to_domain from pybotx.models.users import UserFromSearch -from pydantic import Field +from pydantic import Field, field_validator class BotXAPISearchUserResult(VerifiedPayloadBaseModel): @@ -32,6 +32,14 @@ class BotXAPISearchUserResult(VerifiedPayloadBaseModel): created_at: datetime | None = None updated_at: datetime | None = None + @field_validator("ip_phone", "other_ip_phone", "other_phone", mode="before") + @classmethod + def convert_phone_to_string(cls, value: str | int | None) -> str | None: + if value is None: + return None + + return str(value) + class BotXAPISearchUserResponsePayload(VerifiedPayloadBaseModel): status: Literal["ok"] diff --git a/tests/client/users_api/test_search_user_by_emails.py b/tests/client/users_api/test_search_user_by_emails.py index 72d61425..3fefc536 100644 --- a/tests/client/users_api/test_search_user_by_emails.py +++ b/tests/client/users_api/test_search_user_by_emails.py @@ -85,3 +85,45 @@ async def test__search_user_by_email_without_data__succeed( # - Assert - assert_deep_equal(users, [user_from_search_without_data]) assert endpoint.called + + +async def test__search_user_by_email_with_trusts_search__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data: UserFromSearch, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + # - Arrange - + user_emails = ["ad_user@cts.com"] + + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", + json={ + "emails": user_emails, + "trusts_search": True, + "partial_response": True, + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload([user_from_search_with_data_json]), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + users = await bot.search_user_by_emails( + bot_id=bot_id, + emails=user_emails, + trusts_search=True, + partial_response=True, + ) + + # - Assert - + assert_deep_equal(users, [user_from_search_with_data]) + assert endpoint.called diff --git a/tests/client/users_api/test_search_user_by_huid.py b/tests/client/users_api/test_search_user_by_huid.py index 51a47cc2..7ada442b 100644 --- a/tests/client/users_api/test_search_user_by_huid.py +++ b/tests/client/users_api/test_search_user_by_huid.py @@ -82,6 +82,43 @@ async def test__search_user_by_huid__succeed( assert endpoint.called +async def test__search_user_by_huid_with_numeric_ip_phone__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data: UserFromSearch, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + # - Arrange - + user_from_search_with_data_json["ip_phone"] = 1271020 + + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_huid", + params={"user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_with_data_json), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + user = await bot.search_user_by_huid( + bot_id=bot_id, + huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"), + ) + + # - Assert - + assert user.ip_phone == "1271020" + assert_deep_equal(user, user_from_search_with_data) + assert endpoint.called + + async def test__search_user_by_huid_without_data__succeed( respx_mock: MockRouter, host: str, diff --git a/tests/client/users_api/test_update_user_profile.py b/tests/client/users_api/test_update_user_profile.py index da713334..06359d93 100644 --- a/tests/client/users_api/test_update_user_profile.py +++ b/tests/client/users_api/test_update_user_profile.py @@ -5,7 +5,10 @@ import pytest from respx.router import MockRouter -from pybotx.client.exceptions.users import InvalidProfileDataError +from pybotx.client.exceptions.users import ( + InvalidProfileDataError, + UserProfileUpdateUnavailableError, +) from pybotx.models.attachments import AttachmentImage from pybotx.models.enums import AttachmentTypes from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload @@ -168,3 +171,46 @@ async def test__update_user_profile__invalid_profile_data_error( # - Assert - assert endpoint.called + + +@pytest.mark.parametrize( + "reason", + [ + "error_from_ad_phonebook_service", + "unexpected_error", + ], +) +async def test__update_user_profile__service_unavailable_error( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, + reason: str, +) -> None: + # - Arrange - + request = BotXRequest( + method="PUT", + path="/api/v3/botx/users/update_profile", + json={ + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + error_payload(reason), + HTTPStatus.SERVICE_UNAVAILABLE, + ) + + # - Act - + async with bot_factory() as bot: + with pytest.raises(UserProfileUpdateUnavailableError) as exc: + await bot.update_user_profile( + bot_id=bot_id, + user_huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + ) + + # - Assert - + assert reason in str(exc.value) + assert endpoint.called diff --git a/tests/client/users_api/test_users_as_csv.py b/tests/client/users_api/test_users_as_csv.py index 95315b2c..95b98fdc 100644 --- a/tests/client/users_api/test_users_as_csv.py +++ b/tests/client/users_api/test_users_as_csv.py @@ -138,3 +138,56 @@ async def test__users_as_csv__succeed( ), ], ) + + +async def test__users_as_csv_with_documented_columns_only__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/users_as_csv", + params={"cts_user": True, "unregistered": True, "botx": False}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + response_json=None, + status=HTTPStatus.OK, + response_content=( + b"HUID,AD Login,Domain,AD E-mail,Name,Sync source,Active,Kind,Company,Department,Position,Manager,Manager HUID\n" + b"dbc8934f-d0d7-4a9e-89df-d45c137a851c,test_user_17,cts.example.com,,test_user_17,ad,true,cts_user,Company,Department,Position,Manager John,13a6909c-bce1-4dbf-8359-efb7ef8e5b34\n" + ), + ) + + users_from_csv = [] + + async with bot_factory() as bot: + async with bot.users_as_csv(bot_id=bot_id) as users: + async for user in users: + users_from_csv.append(user) + + assert endpoint.called + assert_deep_equal( + users_from_csv, + [ + UserFromCSV( + huid=UUID("dbc8934f-d0d7-4a9e-89df-d45c137a851c"), + ad_login="test_user_17", + ad_domain="cts.example.com", + username="test_user_17", + sync_source=SyncSourceTypes.AD, + active=True, + user_kind=UserKinds.CTS_USER, + email=None, + company="Company", + department="Department", + position="Position", + manager="Manager John", + manager_huid=UUID("13a6909c-bce1-4dbf-8359-efb7ef8e5b34"), + ), + ], + )