diff --git a/mpt_api_client/resources/accounts/account.py b/mpt_api_client/resources/accounts/account.py index 1a07972d..52fef6ab 100644 --- a/mpt_api_client/resources/accounts/account.py +++ b/mpt_api_client/resources/accounts/account.py @@ -6,6 +6,10 @@ UpdateMixin, ) from mpt_api_client.models import Model +from mpt_api_client.resources.accounts.accounts_users import ( + AccountsUsersService, + AsyncAccountsUsersService, +) from mpt_api_client.resources.accounts.mixins import ( ActivatableMixin, AsyncActivatableMixin, @@ -39,6 +43,12 @@ class AccountsService( ): """Accounts service.""" + def users(self, account_id: str) -> AccountsUsersService: + """Return account users service.""" + return AccountsUsersService( + http_client=self.http_client, endpoint_params={"account_id": account_id} + ) + class AsyncAccountsService( AsyncCreateMixin[Account], @@ -50,3 +60,9 @@ class AsyncAccountsService( AccountsServiceConfig, ): """Async Accounts service.""" + + def users(self, account_id: str) -> AsyncAccountsUsersService: + """Return account users service.""" + return AsyncAccountsUsersService( + http_client=self.http_client, endpoint_params={"account_id": account_id} + ) diff --git a/mpt_api_client/resources/accounts/accounts_users.py b/mpt_api_client/resources/accounts/accounts_users.py new file mode 100644 index 00000000..d46e451c --- /dev/null +++ b/mpt_api_client/resources/accounts/accounts_users.py @@ -0,0 +1,48 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncUpdateMixin, + CreateMixin, + DeleteMixin, + UpdateMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.resources.accounts.mixins import ( + AsyncInvitableMixin, + InvitableMixin, +) + + +class AccountsUser(Model): + """Account User Model.""" + + +class AccountsUsersServiceConfig: + """Account Users Service Configuration.""" + + _endpoint = "/public/v1/accounts/accounts/{account_id}/users" + _model_class = AccountsUser + _collection_key = "data" + + +class AccountsUsersService( + UpdateMixin[AccountsUser], + DeleteMixin, + CreateMixin[AccountsUser], + InvitableMixin[AccountsUser], + Service[AccountsUser], + AccountsUsersServiceConfig, +): + """Account Users Service.""" + + +class AsyncAccountsUsersService( + AsyncUpdateMixin[AccountsUser], + AsyncDeleteMixin, + AsyncCreateMixin[AccountsUser], + AsyncService[AccountsUser], + AsyncInvitableMixin[AccountsUser], + AccountsUsersServiceConfig, +): + """Asynchronous Account Users Service.""" diff --git a/mpt_api_client/resources/accounts/mixins.py b/mpt_api_client/resources/accounts/mixins.py index 8d4b7921..10af417c 100644 --- a/mpt_api_client/resources/accounts/mixins.py +++ b/mpt_api_client/resources/accounts/mixins.py @@ -97,6 +97,43 @@ def unblock(self, resource_id: str, resource_data: ResourceData | None = None) - ) +class InvitableMixin[Model]: + """Invitable mixin for sending and managing invites for resources.""" + + def accept_invite(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Accept an invite for a resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "accept-invite", json=resource_data + ) + + def resend_invite(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Resend an invite to a resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "resend-invite", json=resource_data + ) + + def send_new_invite(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Send a new invite to a resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "send-new-invite", json=resource_data + ) + + class AsyncActivatableMixin[Model]: """Async activatable mixin for activating, enabling, disabling and deactivating resources.""" @@ -190,3 +227,46 @@ async def unblock(self, resource_id: str, resource_data: ResourceData | None = N return await self._resource_action( # type: ignore[attr-defined, no-any-return] resource_id, "POST", "unblock", json=resource_data ) + + +class AsyncInvitableMixin[Model]: + """Asynchronous Invitable mixin for sending and managing invites for resources.""" + + async def accept_invite( + self, resource_id: str, resource_data: ResourceData | None = None + ) -> Model: + """Accept an invite for a resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "accept-invite", json=resource_data + ) + + async def resend_invite( + self, resource_id: str, resource_data: ResourceData | None = None + ) -> Model: + """Resend an invite to a resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "resend-invite", json=resource_data + ) + + async def send_new_invite( + self, resource_id: str, resource_data: ResourceData | None = None + ) -> Model: + """Send a new invite to a resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "send-new-invite", json=resource_data + ) diff --git a/setup.cfg b/setup.cfg index 80617e8f..343cab29 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,7 +44,7 @@ per-file-ignores = tests/http/test_service.py: WPS204 WPS202 tests/http/test_mixins.py: WPS204 WPS202 tests/resources/catalog/test_products.py: WPS202 WPS210 - tests/resources/*/test_mixins.py: WPS118 WPS202 WPS204 + tests/resources/*/test_mixins.py: WPS118 WPS202 WPS204 WPS235 tests/test_mpt_client.py: WPS235 tests/*: diff --git a/tests/resources/accounts/test_account.py b/tests/resources/accounts/test_account.py index 46f5ab1e..64b39e24 100644 --- a/tests/resources/accounts/test_account.py +++ b/tests/resources/accounts/test_account.py @@ -1,6 +1,10 @@ import pytest from mpt_api_client.resources.accounts.account import AccountsService, AsyncAccountsService +from mpt_api_client.resources.accounts.accounts_users import ( + AccountsUsersService, + AsyncAccountsUsersService, +) @pytest.fixture @@ -13,6 +17,20 @@ def async_account_service(async_http_client): return AsyncAccountsService(http_client=async_http_client) +@pytest.fixture +def accounts_users_service(http_client): + return AccountsUsersService( + http_client=http_client, endpoint_params={"account_id": "ACC-0000-0001"} + ) + + +@pytest.fixture +def async_accounts_users_service(async_http_client): + return AsyncAccountsUsersService( + http_client=async_http_client, endpoint_params={"account_id": "ACC-0000-0001"} + ) + + @pytest.mark.parametrize( "method", ["get", "create", "update", "enable", "disable", "activate", "deactivate", "validate"] ) @@ -25,3 +43,29 @@ def test_mixins_present(account_service, method): ) def test_async_mixins_present(async_account_service, method): assert hasattr(async_account_service, method) + + +@pytest.mark.parametrize( + ("service_method", "expected_service_class"), + [ + ("users", AccountsUsersService), + ], +) +def test_property_services(account_service, service_method, expected_service_class): + service = getattr(account_service, service_method)("ACC-0000-0001") + + assert isinstance(service, expected_service_class) + assert service.endpoint_params == {"account_id": "ACC-0000-0001"} + + +@pytest.mark.parametrize( + ("service_method", "expected_service_class"), + [ + ("users", AsyncAccountsUsersService), + ], +) +def test_async_property_services(async_account_service, service_method, expected_service_class): + service = getattr(async_account_service, service_method)("ACC-0000-0001") + + assert isinstance(service, expected_service_class) + assert service.endpoint_params == {"account_id": "ACC-0000-0001"} diff --git a/tests/resources/accounts/test_accounts_users.py b/tests/resources/accounts/test_accounts_users.py new file mode 100644 index 00000000..e0f428b6 --- /dev/null +++ b/tests/resources/accounts/test_accounts_users.py @@ -0,0 +1,46 @@ +import pytest + +from mpt_api_client.resources.accounts.accounts_users import ( + AccountsUsersService, + AsyncAccountsUsersService, +) + + +@pytest.fixture +def accounts_users_service(http_client): + return AccountsUsersService( + http_client=http_client, endpoint_params={"account_id": "ACC-0000-0001"} + ) + + +@pytest.fixture +def async_accounts_users_service(async_http_client): + return AsyncAccountsUsersService( + http_client=async_http_client, endpoint_params={"account_id": "ACC-0000-0001"} + ) + + +def test_endpoint(accounts_users_service): + assert accounts_users_service.endpoint == "/public/v1/accounts/accounts/ACC-0000-0001/users" + + +def test_async_endpoint(async_accounts_users_service): + assert ( + async_accounts_users_service.endpoint == "/public/v1/accounts/accounts/ACC-0000-0001/users" + ) + + +@pytest.mark.parametrize( + "method", + ["get", "create", "update", "accept_invite", "resend_invite", "send_new_invite"], +) +def test_methods_present(accounts_users_service, method): + assert hasattr(accounts_users_service, method) + + +@pytest.mark.parametrize( + "method", + ["get", "create", "update", "accept_invite", "resend_invite", "send_new_invite"], +) +def test_async_methods_present(async_accounts_users_service, method): + assert hasattr(async_accounts_users_service, method) diff --git a/tests/resources/accounts/test_mixins.py b/tests/resources/accounts/test_mixins.py index 5e24564f..f9b0954b 100644 --- a/tests/resources/accounts/test_mixins.py +++ b/tests/resources/accounts/test_mixins.py @@ -8,9 +8,11 @@ AsyncActivatableMixin, AsyncBlockableMixin, AsyncEnablableMixin, + AsyncInvitableMixin, AsyncValidateMixin, BlockableMixin, EnablableMixin, + InvitableMixin, ValidateMixin, ) from tests.conftest import DummyModel @@ -88,6 +90,24 @@ class DummyAsyncBlockableService( _collection_key = "data" +class DummyInvitableService( + InvitableMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/invitable/" + _model_class = DummyModel + _collection_key = "data" + + +class DummyAsyncInvitableService( + AsyncInvitableMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/invitable/" + _model_class = DummyModel + _collection_key = "data" + + @pytest.fixture def activatable_service(http_client): return DummyActivatableService(http_client=http_client) @@ -128,6 +148,16 @@ def async_blockable_service(async_http_client): return DummyAsyncBlockableService(http_client=async_http_client) +@pytest.fixture +def invitable_service(http_client): + return DummyInvitableService(http_client=http_client) + + +@pytest.fixture +def async_invitable_service(async_http_client): + return DummyAsyncInvitableService(http_client=async_http_client) + + @pytest.mark.parametrize( ("action", "input_status"), [ @@ -606,3 +636,137 @@ async def test_async_blockable_resource_actions_no_data( assert request.content == request_expected_content assert blockable_obj.to_dict() == response_expected_data assert isinstance(blockable_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("accept_invite", {"id": "OBJ-0000-0001", "status": "update"}), + ("resend_invite", {"id": "OBJ-0000-0001", "status": "update"}), + ("send_new_invite", {"id": "OBJ-0000-0001", "status": "update"}), + ], +) +def test_invitable_resource_actions(invitable_service, action, input_status): + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/" + f"dummy/invitable/OBJ-0000-0001/{action.replace('_', '-')}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + invitable_obj = getattr(invitable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert invitable_obj.to_dict() == response_expected_data + assert isinstance(invitable_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("accept_invite", None), + ("resend_invite", None), + ("send_new_invite", None), + ], +) +def test_invitable_resource_actions_no_data(invitable_service, action, input_status): + request_expected_content = b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/" + f"dummy/invitable/OBJ-0000-0001/{action.replace('_', '-')}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + invitable_obj = getattr(invitable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert invitable_obj.to_dict() == response_expected_data + assert isinstance(invitable_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("accept_invite", {"id": "OBJ-0000-0001", "status": "update"}), + ("resend_invite", {"id": "OBJ-0000-0001", "status": "update"}), + ("send_new_invite", {"id": "OBJ-0000-0001", "status": "update"}), + ], +) +async def test_async_invitable_resource_actions(async_invitable_service, action, input_status): + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/" + f"dummy/invitable/OBJ-0000-0001/{action.replace('_', '-')}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + invitable_obj = await getattr(async_invitable_service, action)( + "OBJ-0000-0001", input_status + ) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert invitable_obj.to_dict() == response_expected_data + assert isinstance(invitable_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("accept_invite", None), + ("resend_invite", None), + ("send_new_invite", None), + ], +) +async def test_async_invitable_resource_actions_no_data( + async_invitable_service, action, input_status +): + request_expected_content = b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/" + f"dummy/invitable/OBJ-0000-0001/{action.replace('_', '-')}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + invitable_obj = await getattr(async_invitable_service, action)( + "OBJ-0000-0001", input_status + ) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert invitable_obj.to_dict() == response_expected_data + assert isinstance(invitable_obj, DummyModel)